From bd8a3ac0f9012d0b0bbcc42f7d17a0a82cb7ef07 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 16:14:41 -0700 Subject: [PATCH 001/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 4 +++ .../schema/json/ServerNotification.json | 20 +++++++++++ .../codex_app_server_protocol.schemas.json | 20 +++++++++++ .../codex_app_server_protocol.v2.schemas.json | 20 +++++++++++ .../json/v2/TurnCompletedNotification.json | 14 ++++++++ .../json/v2/TurnStartedNotification.json | 6 ++++ .../v2/TurnCompletedNotification.ts | 10 +++++- .../typescript/v2/TurnStartedNotification.ts | 6 +++- .../app-server-protocol/src/protocol/v2.rs | 9 +++++ .../app-server/src/bespoke_event_handling.rs | 36 +++++++++++++++++-- codex-rs/app-server/src/in_process.rs | 2 ++ codex-rs/app-server/src/thread_state.rs | 1 + ...event_processor_with_human_output_tests.rs | 10 ++++++ ...event_processor_with_jsonl_output_tests.rs | 2 ++ codex-rs/exec/src/lib.rs | 1 + .../tests/event_processor_with_json_output.rs | 21 +++++++++++ codex-rs/tui/src/app.rs | 3 ++ codex-rs/tui/src/app/app_server_adapter.rs | 6 ++++ .../tui/src/app/pending_interactive_replay.rs | 2 ++ codex-rs/tui/src/chatwidget.rs | 2 ++ .../tui/src/chatwidget/tests/app_server.rs | 8 +++++ .../src/chatwidget/tests/history_replay.rs | 2 ++ .../tui/src/chatwidget/tests/plan_mode.rs | 3 ++ 23 files changed, 204 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..7ff1322d72 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1061,6 +1061,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } @@ -1835,6 +1837,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, } ) ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..251327a560 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3566,6 +3566,19 @@ }, "TurnCompletedNotification": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -3574,6 +3587,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -3680,6 +3694,11 @@ }, "TurnStartedNotification": { "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -3688,6 +3707,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 a589903032..acc274aaff 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 @@ -14364,6 +14364,19 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -14372,6 +14385,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -14640,6 +14654,11 @@ "TurnStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -14648,6 +14667,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 f041f8aae8..2421630d43 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 @@ -12219,6 +12219,19 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -12227,6 +12240,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -12495,6 +12509,11 @@ "TurnStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -12503,6 +12522,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 770cc920cf..3780541077 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1559,6 +1559,19 @@ } }, "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -1567,6 +1580,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], 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 761ddc9a62..94af685a0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1559,6 +1559,11 @@ } }, "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1567,6 +1572,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts index e1b151bfa7..66b05f3514 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts @@ -3,4 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Turn } from "./Turn"; -export type TurnCompletedNotification = { threadId: string, turn: Turn, }; +export type TurnCompletedNotification = { threadId: string, turn: Turn, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts index 34f71b2465..c8ddd257e3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Turn } from "./Turn"; -export type TurnStartedNotification = { threadId: string, turn: Turn, }; +export type TurnStartedNotification = { threadId: string, turn: Turn, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt: number, }; \ No newline at end of file diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..538293f1f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5043,6 +5043,9 @@ pub struct ThreadNameUpdatedNotification { pub struct TurnStartedNotification { pub thread_id: String, pub turn: Turn, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number")] + pub created_at: i64, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -5069,6 +5072,12 @@ pub struct Usage { pub struct TurnCompletedNotification { pub thread_id: String, pub turn: Turn, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number")] + pub completed_at: i64, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..6e288da81e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -141,6 +141,8 @@ use std::convert::TryFrom; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; @@ -289,6 +291,7 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = TurnStartedNotification { thread_id: conversation_id.to_string(), turn, + created_at: set_turn_started_at(&thread_state).await, }; outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) @@ -1947,8 +1950,10 @@ async fn emit_turn_completed_with_status( event_turn_id: String, status: TurnStatus, error: Option, + started_at_ms: Option, outgoing: &ThreadScopedOutgoingMessageSender, ) { + let completed_at_ms = now_unix_timestamp_millis(); let notification = TurnCompletedNotification { thread_id: conversation_id.to_string(), turn: Turn { @@ -1957,6 +1962,9 @@ async fn emit_turn_completed_with_status( error, status, }, + completed_at: completed_at_ms / 1000, + duration_ms: started_at_ms + .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_ms)), }; outgoing .send_server_notification(ServerNotification::TurnCompleted(notification)) @@ -2105,13 +2113,22 @@ async fn handle_turn_complete( thread_state: &Arc>, ) { let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; + let started_at_ms = turn_summary.started_at_ms; let (status, error) = match turn_summary.last_error { Some(error) => (TurnStatus::Failed, Some(error)), None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + started_at_ms, + outgoing, + ) + .await; } async fn handle_turn_interrupted( @@ -2120,18 +2137,33 @@ async fn handle_turn_interrupted( outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + turn_summary.started_at_ms, outgoing, ) .await; } +async fn set_turn_started_at(thread_state: &Arc>) -> i64 { + let started_at_ms = now_unix_timestamp_millis(); + let mut state = thread_state.lock().await; + state.turn_summary.started_at_ms = Some(started_at_ms); + started_at_ms / 1000 +} + +fn now_unix_timestamp_millis() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + async fn handle_thread_rollback_failed( _conversation_id: ThreadId, message: String, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..c2d535a126 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -824,6 +824,8 @@ mod tests { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) )); } diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index be5478dd51..e7af824009 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -44,6 +44,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at_ms: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..18d08f3bcd 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -157,6 +157,8 @@ fn turn_completed_recovers_final_message_from_turn_items() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: vec![ThreadItem::AgentMessage { @@ -201,6 +203,8 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: vec![ThreadItem::AgentMessage { @@ -246,6 +250,8 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: Vec::new(), @@ -286,6 +292,8 @@ fn turn_failed_clears_stale_final_message() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: Vec::new(), @@ -327,6 +335,8 @@ fn turn_interrupted_clears_stale_final_message() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: Vec::new(), diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..1f8a00b579 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -29,6 +29,8 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), items: Vec::new(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5a396a9810..5609fd0344 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -717,6 +717,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { TurnStartedNotification { thread_id: response.review_thread_id.clone(), turn: response.turn.clone(), + created_at: 0, }, )); let task_id = response.turn.id; 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 5491e895e2..ed28aa8c85 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -145,6 +145,7 @@ fn turn_started_emits_turn_started_event() { status: TurnStatus::InProgress, error: None, }, + created_at: 0, })); assert_eq!( @@ -1067,6 +1068,8 @@ fn plan_update_emits_started_then_updated_then_completed() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); assert_eq!( @@ -1123,6 +1126,8 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1202,6 +1207,8 @@ fn token_usage_update_is_emitted_on_turn_completion() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); assert_eq!( @@ -1237,6 +1244,8 @@ fn turn_completion_recovers_final_message_from_turn_items() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1311,6 +1320,8 @@ fn turn_completion_reconciles_started_items_from_turn_items() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1368,6 +1379,8 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1408,6 +1421,8 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1456,6 +1471,8 @@ fn failed_turn_clears_stale_final_message() { codex_error_info: None, }), }, + completed_at: 0, + duration_ms: None, }, )); @@ -1479,6 +1496,8 @@ fn turn_completion_falls_back_to_final_plan_text() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1527,6 +1546,8 @@ fn turn_failure_prefers_structured_error_message() { status: TurnStatus::Failed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e262b55a31..759bf52c7c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9191,6 +9191,7 @@ guardian_approval = true ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + created_at: 0, }) } @@ -9202,6 +9203,8 @@ guardian_approval = true ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), turn: test_turn(turn_id, status, Vec::new()), + completed_at: 0, + duration_ms: None, }) } diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..3a994353b8 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -1104,6 +1104,8 @@ mod tests { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }), ) .expect("notification should bridge"); @@ -1316,6 +1318,8 @@ mod tests { status: TurnStatus::Interrupted, error: None, }, + completed_at: 0, + duration_ms: None, }), ) .expect("notification should bridge"); @@ -1352,6 +1356,8 @@ mod tests { additional_details: None, }), }, + completed_at: 0, + duration_ms: None, }), ) .expect("notification should bridge"); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..408e4d0521 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -677,6 +677,8 @@ mod tests { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 02429e9d9b..63b201d111 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5911,6 +5911,8 @@ impl ChatWidget { status, error, }, + completed_at: 0, + duration_ms: None, }, Some(replay_kind), ); diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..a5d1a706fd 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -94,6 +94,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -133,6 +134,8 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( status: AppServerTurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }), /*replay_kind*/ None, ); @@ -416,6 +419,7 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -451,6 +455,8 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { additional_details: None, }), }, + completed_at: 0, + duration_ms: None, }), /*replay_kind*/ None, ); @@ -472,6 +478,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -526,6 +533,7 @@ async fn live_app_server_server_overloaded_error_renders_warning() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..5fc1471f5d 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -537,6 +537,7 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), Some(ReplayKind::ThreadSnapshot), ); @@ -687,6 +688,7 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..7b2c5d8eb6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -851,6 +851,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -894,6 +895,8 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { status: AppServerTurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }), /*replay_kind*/ None, ); From 8068f5dc2ce0dc27ace4a5b2a541313038b2d686 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 16:52:32 -0700 Subject: [PATCH 002/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 392 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 369 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 50 ++- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../app-server/tests/suite/v2/turn_start.rs | 75 ++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 20 files changed, 1176 insertions(+), 38 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..d9a080aa1b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,173 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + AppServerUserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + AppServerUserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + created_at: 455, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + }, + completed_at: 456, + duration_ms: Some(1234), + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +854,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..930ae8bc58 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,116 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = u64::try_from(notification.created_at).ok(); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: u64::try_from(notification.completed_at).unwrap_or_default(), + duration_ms: notification + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +446,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6e288da81e..04d3465f08 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -293,6 +295,8 @@ pub(crate) async fn apply_bespoke_event_handling( turn, created_at: set_turn_started_at(&thread_state).await, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -305,7 +309,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1951,6 +1962,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, started_at_ms: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let completed_at_ms = now_unix_timestamp_millis(); @@ -1966,6 +1978,10 @@ async fn emit_turn_completed_with_status( duration_ms: started_at_ms .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_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; @@ -2109,6 +2125,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2126,6 +2143,7 @@ async fn handle_turn_complete( status, error, started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -2145,6 +2163,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, + /*analytics_events_client*/ None, outgoing, ) .await; @@ -3404,6 +3423,8 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3664,7 +3685,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3678,11 +3706,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..18a8c0b6b0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6449,6 +6449,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7221,6 +7230,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3155a4be9c..5b612791d2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,76 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..986e711348 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2559,6 +2559,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..a5088c4113 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..e0903fd5eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From d180b0846ce44785993b4ef7bf9f18b4d344ecde Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 17:11:09 -0700 Subject: [PATCH 003/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d9a080aa1b..3a0230113c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -173,6 +176,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -223,6 +253,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -292,6 +323,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -887,6 +929,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -928,6 +975,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -947,6 +999,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -975,6 +1028,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -988,6 +1049,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1017,6 +1079,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1044,6 +1107,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1061,6 +1125,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 930ae8bc58..c32d043c90 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,10 +386,23 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -396,6 +413,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -509,6 +527,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -540,6 +559,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 04d3465f08..c381daefbf 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1364,8 +1364,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2208,6 +2214,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2218,6 +2225,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3551,6 +3563,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3605,6 +3618,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5b612791d2..a8552b32d9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From f7d1f457750585332b86ad8c3d5a7f464df25597 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 18:52:32 -0700 Subject: [PATCH 004/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 392 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 369 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 49 ++- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../app-server/tests/suite/v2/turn_start.rs | 75 ++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 20 files changed, 1175 insertions(+), 38 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..d9a080aa1b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,173 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + AppServerUserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + AppServerUserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + created_at: 455, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + }, + completed_at: 456, + duration_ms: Some(1234), + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +854,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..930ae8bc58 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,116 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = u64::try_from(notification.created_at).ok(); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: u64::try_from(notification.completed_at).unwrap_or_default(), + duration_ms: notification + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +446,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6e288da81e..e7918324ea 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -293,6 +295,8 @@ pub(crate) async fn apply_bespoke_event_handling( turn, created_at: set_turn_started_at(&thread_state).await, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -305,7 +309,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1951,6 +1962,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, started_at_ms: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let completed_at_ms = now_unix_timestamp_millis(); @@ -1966,6 +1978,10 @@ async fn emit_turn_completed_with_status( duration_ms: started_at_ms .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_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; @@ -2109,6 +2125,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2126,6 +2143,7 @@ async fn handle_turn_complete( status, error, started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -2145,6 +2163,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, + /*analytics_events_client*/ None, outgoing, ) .await; @@ -3404,6 +3423,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3664,7 +3684,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3678,11 +3705,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..18a8c0b6b0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6449,6 +6449,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7221,6 +7230,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3155a4be9c..5b612791d2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,76 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..986e711348 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2559,6 +2559,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..a5088c4113 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..e0903fd5eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 426b81a41bbb2cbcaa7e0eca3bf5d270fe69ac55 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 18:52:32 -0700 Subject: [PATCH 005/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d9a080aa1b..3a0230113c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -173,6 +176,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -223,6 +253,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -292,6 +323,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -887,6 +929,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -928,6 +975,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -947,6 +999,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -975,6 +1028,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -988,6 +1049,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1017,6 +1079,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1044,6 +1107,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1061,6 +1125,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 930ae8bc58..c32d043c90 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,10 +386,23 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -396,6 +413,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -509,6 +527,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -540,6 +559,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index e7918324ea..18372b40da 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1364,8 +1364,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2208,6 +2214,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2218,6 +2225,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3550,6 +3562,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3604,6 +3617,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5b612791d2..a8552b32d9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 3d1314ca03a1af562e7c8a3a6e1f52637869213f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 18:52:32 -0700 Subject: [PATCH 006/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 4 +++ .../schema/json/ServerNotification.json | 20 +++++++++++ .../codex_app_server_protocol.schemas.json | 20 +++++++++++ .../codex_app_server_protocol.v2.schemas.json | 20 +++++++++++ .../json/v2/TurnCompletedNotification.json | 14 ++++++++ .../json/v2/TurnStartedNotification.json | 6 ++++ .../v2/TurnCompletedNotification.ts | 10 +++++- .../typescript/v2/TurnStartedNotification.ts | 6 +++- .../app-server-protocol/src/protocol/v2.rs | 9 +++++ .../app-server/src/bespoke_event_handling.rs | 36 +++++++++++++++++-- codex-rs/app-server/src/in_process.rs | 2 ++ codex-rs/app-server/src/thread_state.rs | 1 + ...event_processor_with_human_output_tests.rs | 10 ++++++ ...event_processor_with_jsonl_output_tests.rs | 2 ++ codex-rs/exec/src/lib.rs | 1 + .../tests/event_processor_with_json_output.rs | 21 +++++++++++ codex-rs/tui/src/app.rs | 3 ++ codex-rs/tui/src/app/app_server_adapter.rs | 6 ++++ .../tui/src/app/pending_interactive_replay.rs | 2 ++ codex-rs/tui/src/chatwidget.rs | 2 ++ .../tui/src/chatwidget/tests/app_server.rs | 8 +++++ .../src/chatwidget/tests/history_replay.rs | 2 ++ .../tui/src/chatwidget/tests/plan_mode.rs | 3 ++ 23 files changed, 204 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..7ff1322d72 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1061,6 +1061,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } @@ -1835,6 +1837,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, } ) ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..251327a560 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3566,6 +3566,19 @@ }, "TurnCompletedNotification": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -3574,6 +3587,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -3680,6 +3694,11 @@ }, "TurnStartedNotification": { "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -3688,6 +3707,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 a589903032..acc274aaff 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 @@ -14364,6 +14364,19 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -14372,6 +14385,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -14640,6 +14654,11 @@ "TurnStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -14648,6 +14667,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 f041f8aae8..2421630d43 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 @@ -12219,6 +12219,19 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -12227,6 +12240,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -12495,6 +12509,11 @@ "TurnStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -12503,6 +12522,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 770cc920cf..3780541077 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1559,6 +1559,19 @@ } }, "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -1567,6 +1580,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], 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 761ddc9a62..94af685a0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1559,6 +1559,11 @@ } }, "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1567,6 +1572,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts index e1b151bfa7..fbe9e11b5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts @@ -3,4 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Turn } from "./Turn"; -export type TurnCompletedNotification = { threadId: string, turn: Turn, }; +export type TurnCompletedNotification = { threadId: string, turn: Turn, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts index 34f71b2465..8c31149506 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Turn } from "./Turn"; -export type TurnStartedNotification = { threadId: string, turn: Turn, }; +export type TurnStartedNotification = { threadId: string, turn: Turn, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt: number, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..538293f1f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5043,6 +5043,9 @@ pub struct ThreadNameUpdatedNotification { pub struct TurnStartedNotification { pub thread_id: String, pub turn: Turn, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number")] + pub created_at: i64, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -5069,6 +5072,12 @@ pub struct Usage { pub struct TurnCompletedNotification { pub thread_id: String, pub turn: Turn, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number")] + pub completed_at: i64, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..6e288da81e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -141,6 +141,8 @@ use std::convert::TryFrom; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; @@ -289,6 +291,7 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = TurnStartedNotification { thread_id: conversation_id.to_string(), turn, + created_at: set_turn_started_at(&thread_state).await, }; outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) @@ -1947,8 +1950,10 @@ async fn emit_turn_completed_with_status( event_turn_id: String, status: TurnStatus, error: Option, + started_at_ms: Option, outgoing: &ThreadScopedOutgoingMessageSender, ) { + let completed_at_ms = now_unix_timestamp_millis(); let notification = TurnCompletedNotification { thread_id: conversation_id.to_string(), turn: Turn { @@ -1957,6 +1962,9 @@ async fn emit_turn_completed_with_status( error, status, }, + completed_at: completed_at_ms / 1000, + duration_ms: started_at_ms + .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_ms)), }; outgoing .send_server_notification(ServerNotification::TurnCompleted(notification)) @@ -2105,13 +2113,22 @@ async fn handle_turn_complete( thread_state: &Arc>, ) { let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; + let started_at_ms = turn_summary.started_at_ms; let (status, error) = match turn_summary.last_error { Some(error) => (TurnStatus::Failed, Some(error)), None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + started_at_ms, + outgoing, + ) + .await; } async fn handle_turn_interrupted( @@ -2120,18 +2137,33 @@ async fn handle_turn_interrupted( outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + turn_summary.started_at_ms, outgoing, ) .await; } +async fn set_turn_started_at(thread_state: &Arc>) -> i64 { + let started_at_ms = now_unix_timestamp_millis(); + let mut state = thread_state.lock().await; + state.turn_summary.started_at_ms = Some(started_at_ms); + started_at_ms / 1000 +} + +fn now_unix_timestamp_millis() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + async fn handle_thread_rollback_failed( _conversation_id: ThreadId, message: String, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..c2d535a126 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -824,6 +824,8 @@ mod tests { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) )); } diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index be5478dd51..e7af824009 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -44,6 +44,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at_ms: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..18d08f3bcd 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -157,6 +157,8 @@ fn turn_completed_recovers_final_message_from_turn_items() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: vec![ThreadItem::AgentMessage { @@ -201,6 +203,8 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: vec![ThreadItem::AgentMessage { @@ -246,6 +250,8 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: Vec::new(), @@ -286,6 +292,8 @@ fn turn_failed_clears_stale_final_message() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: Vec::new(), @@ -327,6 +335,8 @@ fn turn_interrupted_clears_stale_final_message() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: Turn { id: "turn-1".to_string(), items: Vec::new(), diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..1f8a00b579 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -29,6 +29,8 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { thread_id: "thread-1".to_string(), + completed_at: 0, + duration_ms: None, turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), items: Vec::new(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5a396a9810..5609fd0344 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -717,6 +717,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { TurnStartedNotification { thread_id: response.review_thread_id.clone(), turn: response.turn.clone(), + created_at: 0, }, )); let task_id = response.turn.id; 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 5491e895e2..ed28aa8c85 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -145,6 +145,7 @@ fn turn_started_emits_turn_started_event() { status: TurnStatus::InProgress, error: None, }, + created_at: 0, })); assert_eq!( @@ -1067,6 +1068,8 @@ fn plan_update_emits_started_then_updated_then_completed() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); assert_eq!( @@ -1123,6 +1126,8 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1202,6 +1207,8 @@ fn token_usage_update_is_emitted_on_turn_completion() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); assert_eq!( @@ -1237,6 +1244,8 @@ fn turn_completion_recovers_final_message_from_turn_items() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1311,6 +1320,8 @@ fn turn_completion_reconciles_started_items_from_turn_items() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1368,6 +1379,8 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1408,6 +1421,8 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1456,6 +1471,8 @@ fn failed_turn_clears_stale_final_message() { codex_error_info: None, }), }, + completed_at: 0, + duration_ms: None, }, )); @@ -1479,6 +1496,8 @@ fn turn_completion_falls_back_to_final_plan_text() { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); @@ -1527,6 +1546,8 @@ fn turn_failure_prefers_structured_error_message() { status: TurnStatus::Failed, error: None, }, + completed_at: 0, + duration_ms: None, }, )); assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e262b55a31..759bf52c7c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9191,6 +9191,7 @@ guardian_approval = true ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + created_at: 0, }) } @@ -9202,6 +9203,8 @@ guardian_approval = true ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), turn: test_turn(turn_id, status, Vec::new()), + completed_at: 0, + duration_ms: None, }) } diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..3a994353b8 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -1104,6 +1104,8 @@ mod tests { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }), ) .expect("notification should bridge"); @@ -1316,6 +1318,8 @@ mod tests { status: TurnStatus::Interrupted, error: None, }, + completed_at: 0, + duration_ms: None, }), ) .expect("notification should bridge"); @@ -1352,6 +1356,8 @@ mod tests { additional_details: None, }), }, + completed_at: 0, + duration_ms: None, }), ) .expect("notification should bridge"); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..408e4d0521 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -677,6 +677,8 @@ mod tests { status: TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 02429e9d9b..63b201d111 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5911,6 +5911,8 @@ impl ChatWidget { status, error, }, + completed_at: 0, + duration_ms: None, }, Some(replay_kind), ); diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..a5d1a706fd 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -94,6 +94,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -133,6 +134,8 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( status: AppServerTurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }), /*replay_kind*/ None, ); @@ -416,6 +419,7 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -451,6 +455,8 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { additional_details: None, }), }, + completed_at: 0, + duration_ms: None, }), /*replay_kind*/ None, ); @@ -472,6 +478,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -526,6 +533,7 @@ async fn live_app_server_server_overloaded_error_renders_warning() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..5fc1471f5d 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -537,6 +537,7 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), Some(ReplayKind::ThreadSnapshot), ); @@ -687,6 +688,7 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..7b2c5d8eb6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -851,6 +851,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { status: AppServerTurnStatus::InProgress, error: None, }, + created_at: 0, }), /*replay_kind*/ None, ); @@ -894,6 +895,8 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { status: AppServerTurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }), /*replay_kind*/ None, ); From 37390db0099c33b591fbbdf9d00b55b679d9c61f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 18:52:32 -0700 Subject: [PATCH 007/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 392 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 369 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 50 ++- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../app-server/tests/suite/v2/turn_start.rs | 75 ++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 20 files changed, 1176 insertions(+), 38 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..d9a080aa1b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,173 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + AppServerUserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + AppServerUserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + created_at: 455, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + }, + completed_at: 456, + duration_ms: Some(1234), + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +854,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..930ae8bc58 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,116 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = u64::try_from(notification.created_at).ok(); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: u64::try_from(notification.completed_at).unwrap_or_default(), + duration_ms: notification + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +446,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6e288da81e..718f44b7f0 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -293,6 +295,8 @@ pub(crate) async fn apply_bespoke_event_handling( turn, created_at: set_turn_started_at(&thread_state).await, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -305,7 +309,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1951,6 +1962,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, started_at_ms: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let completed_at_ms = now_unix_timestamp_millis(); @@ -1966,6 +1978,10 @@ async fn emit_turn_completed_with_status( duration_ms: started_at_ms .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_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; @@ -2109,6 +2125,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2126,6 +2143,7 @@ async fn handle_turn_complete( status, error, started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -2145,6 +2163,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, + /*analytics_events_client*/ None, outgoing, ) .await; @@ -3316,6 +3335,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3404,6 +3424,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3664,7 +3685,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3678,11 +3706,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..18a8c0b6b0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6449,6 +6449,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7221,6 +7230,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3155a4be9c..5b612791d2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,76 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..986e711348 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2559,6 +2559,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..a5088c4113 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..e0903fd5eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 0e16d53fb76155c39395f8f258a2c8cc8f345d66 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 18:52:32 -0700 Subject: [PATCH 008/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d9a080aa1b..3a0230113c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -173,6 +176,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -223,6 +253,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -292,6 +323,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -887,6 +929,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -928,6 +975,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -947,6 +999,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -975,6 +1028,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -988,6 +1049,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1017,6 +1079,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1044,6 +1107,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1061,6 +1125,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 930ae8bc58..c32d043c90 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,10 +386,23 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -396,6 +413,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -509,6 +527,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -540,6 +559,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 718f44b7f0..4dd2cd367c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1364,8 +1364,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2208,6 +2214,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2218,6 +2225,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3551,6 +3563,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3605,6 +3618,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5b612791d2..a8552b32d9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 98ecd0a770d65542cf308b5faadf92baee53227b Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 22:06:55 -0700 Subject: [PATCH 009/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++++++- .../schema/json/ClientRequest.json | 18 ++++ .../codex_app_server_protocol.schemas.json | 18 ++++ .../codex_app_server_protocol.v2.schemas.json | 18 ++++ .../schema/json/v2/TurnStartParams.json | 18 ++++ .../schema/typescript/SubmissionType.ts | 5 ++ .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 6 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 2 + codex-rs/core/src/codex.rs | 42 ++++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 ++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 ++ codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 ++ .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 ++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 ++++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 ++ codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 +++- .../chatwidget/tests/composer_submission.rs | 23 +++++ 57 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 3a0230113c..f661615b53 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -231,7 +232,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -904,7 +905,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -950,7 +951,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1038,6 +1039,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 acc274aaff..0771cb0e82 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14616,6 +14623,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 2421630d43..8af7e0d716 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12471,6 +12478,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 538293f1f5..d857c56eee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3981,6 +3982,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 18a8c0b6b0..827b7c74cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, 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 a8552b32d9..f3697c40ce 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1472,6 +1472,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1505,6 +1506,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 986e711348..68000565e2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2735,6 +2735,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3103,6 +3104,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3572,6 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5609fd0344..16d5b2335c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -686,6 +686,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e0903fd5eb..e4874d7519 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 759bf52c7c..32e15a7705 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..3e6be5671f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63b201d111..4976ade348 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7240,8 +7250,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..149f44a281 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -640,6 +640,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From eb06bb1186da95692b9c4b9e396a8e224be0e68c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 22:46:36 -0700 Subject: [PATCH 010/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- codex-rs/core/src/codex.rs | 156 ++++++++++++-- 7 files changed, 450 insertions(+), 16 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f661615b53..98545180ef 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..46db788c2b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,18 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } } /// Notes from the previous real user turn. @@ -4027,47 +4045,159 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + let track_rejection = |tracking: TrackEventsContext, + expected_turn_id: Option, + rejection_reason: TurnSteerRejectionReason, + num_input_images: usize| { + self.services.analytics_events_client.track_turn_steer( + tracking, + CodexTurnSteerEvent { + expected_turn_id, + accepted_turn_id: None, + num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejection_reason), + created_at, + }, + ); + }; + if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let err = SteerInputError::EmptyInput; + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + /*num_input_images*/ 0, + ); + return Err(err); } + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); + let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { + let err = SteerInputError::ExpectedTurnMismatch { expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), - }); + actual: active_turn_id, + }; + track_rejection( + tracking, + Some(expected_turn_id.to_string()), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { + let err = SteerInputError::ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind::Review, - }); + }; + track_rejection( + tracking, + expected_turn_id + .map(str::to_string) + .or_else(|| Some(active_turn_id)), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { + let err = SteerInputError::ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind::Compact, - }); + }; + track_rejection( + tracking, + expected_turn_id + .map(str::to_string) + .or_else(|| Some(active_turn_id)), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); + } + None => { + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + drop(active); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + self.services.analytics_events_client.track_turn_steer( + tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(expected_turn_id), + accepted_turn_id: Some(active_turn_id.clone()), + num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(active_turn_id) } /// Returns the input if there was no task running to inject into. From 065a5e7131753e7ebe74aa41d8651fc800f3ef6c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 11:35:46 -0700 Subject: [PATCH 011/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- codex-rs/core/src/codex.rs | 156 ++++++++++++-- 7 files changed, 450 insertions(+), 16 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f661615b53..98545180ef 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..a220b9593f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,18 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } } /// Notes from the previous real user turn. @@ -4027,47 +4045,159 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + let track_rejection = |tracking: TrackEventsContext, + expected_turn_id: Option, + rejection_reason: TurnSteerRejectionReason, + num_input_images: usize| { + self.services.analytics_events_client.track_turn_steer( + tracking, + CodexTurnSteerEvent { + expected_turn_id, + accepted_turn_id: None, + num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejection_reason), + created_at, + }, + ); + }; + if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let err = SteerInputError::EmptyInput; + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + /*num_input_images*/ 0, + ); + return Err(err); } + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); + let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { + let err = SteerInputError::ExpectedTurnMismatch { expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), - }); + actual: active_turn_id, + }; + track_rejection( + tracking, + Some(expected_turn_id.to_string()), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { + let err = SteerInputError::ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind::Review, - }); + }; + track_rejection( + tracking, + expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id.clone())), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { + let err = SteerInputError::ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind::Compact, - }); + }; + track_rejection( + tracking, + expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id.clone())), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); + } + None => { + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + drop(active); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + self.services.analytics_events_client.track_turn_steer( + tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(expected_turn_id), + accepted_turn_id: Some(active_turn_id.clone()), + num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(active_turn_id) } /// Returns the input if there was no task running to inject into. From 48a7e70eec88e7da71cec7b8410375a9687d4a4e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 2 Apr 2026 22:06:55 -0700 Subject: [PATCH 012/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++++++- .../schema/json/ClientRequest.json | 18 ++++ .../codex_app_server_protocol.schemas.json | 18 ++++ .../codex_app_server_protocol.v2.schemas.json | 18 ++++ .../schema/json/v2/TurnStartParams.json | 18 ++++ .../schema/typescript/SubmissionType.ts | 5 ++ .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 2 + codex-rs/core/src/codex.rs | 42 ++++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 ++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 ++ codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 ++ .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 ++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 ++++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 ++ codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 +++- .../chatwidget/tests/composer_submission.rs | 23 +++++ 57 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 3a0230113c..f661615b53 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -231,7 +232,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -904,7 +905,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -950,7 +951,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1038,6 +1039,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 acc274aaff..0771cb0e82 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14616,6 +14623,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 2421630d43..8af7e0d716 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12471,6 +12478,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 538293f1f5..9c17e68a43 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3981,6 +3982,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 18a8c0b6b0..827b7c74cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, 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 a8552b32d9..f3697c40ce 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1472,6 +1472,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1505,6 +1506,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 986e711348..68000565e2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2735,6 +2735,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3103,6 +3104,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3572,6 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5609fd0344..16d5b2335c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -686,6 +686,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e0903fd5eb..e4874d7519 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 759bf52c7c..32e15a7705 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..3e6be5671f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63b201d111..4976ade348 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7240,8 +7250,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..149f44a281 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -640,6 +640,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From f750e127f4ac7780b2a61a7e482285725fd5d1fe Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 11:35:46 -0700 Subject: [PATCH 013/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- codex-rs/core/src/codex.rs | 156 ++++++++++++-- 7 files changed, 450 insertions(+), 16 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f661615b53..98545180ef 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..a220b9593f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,18 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } } /// Notes from the previous real user turn. @@ -4027,47 +4045,159 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + let track_rejection = |tracking: TrackEventsContext, + expected_turn_id: Option, + rejection_reason: TurnSteerRejectionReason, + num_input_images: usize| { + self.services.analytics_events_client.track_turn_steer( + tracking, + CodexTurnSteerEvent { + expected_turn_id, + accepted_turn_id: None, + num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejection_reason), + created_at, + }, + ); + }; + if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let err = SteerInputError::EmptyInput; + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + /*num_input_images*/ 0, + ); + return Err(err); } + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); + let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { + let err = SteerInputError::ExpectedTurnMismatch { expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), - }); + actual: active_turn_id, + }; + track_rejection( + tracking, + Some(expected_turn_id.to_string()), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { + let err = SteerInputError::ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind::Review, - }); + }; + track_rejection( + tracking, + expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id.clone())), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { + let err = SteerInputError::ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind::Compact, - }); + }; + track_rejection( + tracking, + expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id.clone())), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); + } + None => { + let err = SteerInputError::NoActiveTurn(input); + track_rejection( + fallback_tracking(), + expected_turn_id.map(str::to_string), + err.to_turn_steer_rejection_reason(), + num_input_images, + ); + return Err(err); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + drop(active); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + self.services.analytics_events_client.track_turn_steer( + tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(expected_turn_id), + accepted_turn_id: Some(active_turn_id.clone()), + num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(active_turn_id) } /// Returns the input if there was no task running to inject into. From 8fb8f6dd0088dcc1ad9aa6da029392286b6096ad Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 15:36:30 -0700 Subject: [PATCH 014/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 392 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 369 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 50 ++- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../app-server/tests/suite/v2/turn_start.rs | 75 ++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 20 files changed, 1176 insertions(+), 38 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..d2b42440e0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,173 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + created_at: 455, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + }, + completed_at: 456, + duration_ms: Some(1234), + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +854,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..930ae8bc58 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,116 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = u64::try_from(notification.created_at).ok(); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: u64::try_from(notification.completed_at).unwrap_or_default(), + duration_ms: notification + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +446,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6e288da81e..718f44b7f0 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -293,6 +295,8 @@ pub(crate) async fn apply_bespoke_event_handling( turn, created_at: set_turn_started_at(&thread_state).await, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -305,7 +309,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1951,6 +1962,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, started_at_ms: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let completed_at_ms = now_unix_timestamp_millis(); @@ -1966,6 +1978,10 @@ async fn emit_turn_completed_with_status( duration_ms: started_at_ms .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_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; @@ -2109,6 +2125,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2126,6 +2143,7 @@ async fn handle_turn_complete( status, error, started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -2145,6 +2163,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, + /*analytics_events_client*/ None, outgoing, ) .await; @@ -3316,6 +3335,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3404,6 +3424,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3664,7 +3685,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3678,11 +3706,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..18a8c0b6b0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6449,6 +6449,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7221,6 +7230,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3155a4be9c..5b612791d2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,76 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..986e711348 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2559,6 +2559,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..a5088c4113 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..e0903fd5eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 660111f72385a75cc1a8d3db97ba0729fe7c7cc3 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 15:36:30 -0700 Subject: [PATCH 015/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d2b42440e0..94455d73c4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -173,6 +176,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -223,6 +253,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -292,6 +323,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -887,6 +929,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -928,6 +975,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -947,6 +999,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -975,6 +1028,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -988,6 +1049,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1017,6 +1079,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1044,6 +1107,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1061,6 +1125,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 930ae8bc58..c32d043c90 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,10 +386,23 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -396,6 +413,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -509,6 +527,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -540,6 +559,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 718f44b7f0..4dd2cd367c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1364,8 +1364,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2208,6 +2214,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2218,6 +2225,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3551,6 +3563,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3605,6 +3618,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5b612791d2..a8552b32d9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 2a2a8c83a1bad21a448b657cc726a7820c36b0c8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 15:36:30 -0700 Subject: [PATCH 016/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++++++- .../schema/json/ClientRequest.json | 18 ++++ .../codex_app_server_protocol.schemas.json | 18 ++++ .../codex_app_server_protocol.v2.schemas.json | 18 ++++ .../schema/json/v2/TurnStartParams.json | 18 ++++ .../schema/typescript/SubmissionType.ts | 5 ++ .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 2 + codex-rs/core/src/codex.rs | 42 ++++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 ++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 ++ codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 ++ .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 ++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 ++++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 ++ codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 +++- .../chatwidget/tests/composer_submission.rs | 23 +++++ 57 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 94455d73c4..4fd232fe98 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -231,7 +232,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -904,7 +905,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -950,7 +951,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1038,6 +1039,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 acc274aaff..0771cb0e82 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14616,6 +14623,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 2421630d43..8af7e0d716 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12471,6 +12478,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 538293f1f5..9c17e68a43 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3981,6 +3982,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 18a8c0b6b0..827b7c74cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, 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 a8552b32d9..f3697c40ce 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1472,6 +1472,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1505,6 +1506,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 986e711348..68000565e2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2735,6 +2735,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3103,6 +3104,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3572,6 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5609fd0344..16d5b2335c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -686,6 +686,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e0903fd5eb..e4874d7519 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 759bf52c7c..32e15a7705 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..3e6be5671f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63b201d111..4976ade348 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7240,8 +7250,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..149f44a281 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -640,6 +640,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From ce5325ff24591b44b28ebc19f17e1d2d94b201e5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 15:52:00 -0700 Subject: [PATCH 017/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- codex-rs/core/src/codex.rs | 188 +++++++++++++++-- 7 files changed, 477 insertions(+), 21 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 4fd232fe98..83e6676a14 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..d3d1e8777d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4027,47 +4059,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4701,6 +4849,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4860,11 +5009,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4875,10 +5027,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From b7537fec0a0c2ee9c6c8961381aedb6e56cba208 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 18:02:49 -0700 Subject: [PATCH 018/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/src/bespoke_event_handling.rs | 13 +- .../tests/suite/v2/turn_interrupt.rs | 26 ++- codex-rs/core/src/codex.rs | 188 +++++++++++++++-- 9 files changed, 511 insertions(+), 26 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 4fd232fe98..83e6676a14 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4dd2cd367c..8f5d65f497 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1800,7 +1800,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -2158,6 +2165,7 @@ async fn handle_turn_complete( async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2169,7 +2177,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, - /*analytics_events_client*/ None, + analytics_events_client, outgoing, ) .await; @@ -3392,6 +3400,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..667430dc8e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -5,6 +5,7 @@ use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +23,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -50,7 +54,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +96,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +106,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +129,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +147,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..d3d1e8777d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4027,47 +4059,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4701,6 +4849,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4860,11 +5009,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4875,10 +5027,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From a80ff24a2c6e4d5403a626797caaa6460b3528c9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 18:40:40 -0700 Subject: [PATCH 019/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 392 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 369 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 61 ++- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 41 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1285 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..d2b42440e0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,173 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + created_at: 455, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + }, + completed_at: 456, + duration_ms: Some(1234), + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +854,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..930ae8bc58 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,116 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = u64::try_from(notification.created_at).ok(); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: u64::try_from(notification.completed_at).unwrap_or_default(), + duration_ms: notification + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +446,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6e288da81e..da38311a1c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -293,6 +295,8 @@ pub(crate) async fn apply_bespoke_event_handling( turn, created_at: set_turn_started_at(&thread_state).await, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -305,7 +309,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1783,7 +1794,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1951,6 +1969,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, started_at_ms: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let completed_at_ms = now_unix_timestamp_millis(); @@ -1966,6 +1985,10 @@ async fn emit_turn_completed_with_status( duration_ms: started_at_ms .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_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; @@ -2109,6 +2132,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2126,6 +2150,7 @@ async fn handle_turn_complete( status, error, started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -2134,6 +2159,7 @@ async fn handle_turn_complete( async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2145,6 +2171,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -3316,6 +3343,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3360,6 +3388,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3404,6 +3433,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3664,7 +3694,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3678,11 +3715,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..18a8c0b6b0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6449,6 +6449,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7221,6 +7230,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..df668e757f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,21 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?, + ]) .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +109,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +132,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +150,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 3155a4be9c..6ad3d44485 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..986e711348 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2559,6 +2559,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..a5088c4113 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..e0903fd5eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From aa37e4f15495947532f286fa1110d2f099cc8b64 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 18:42:06 -0700 Subject: [PATCH 020/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d2b42440e0..94455d73c4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -173,6 +176,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -223,6 +253,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -292,6 +323,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -887,6 +929,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -928,6 +975,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -947,6 +999,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -975,6 +1028,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -988,6 +1049,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1017,6 +1079,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1044,6 +1107,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1061,6 +1125,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 930ae8bc58..c32d043c90 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,10 +386,23 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -396,6 +413,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -509,6 +527,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -540,6 +559,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index da38311a1c..8f5d65f497 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1364,8 +1364,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2216,6 +2222,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2226,6 +2233,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3560,6 +3572,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3614,6 +3627,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 6ad3d44485..55227cca03 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 62c424f0175050dd7cb508f5772a19b74b52faf1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 18:42:06 -0700 Subject: [PATCH 021/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 94455d73c4..4fd232fe98 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -231,7 +232,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -904,7 +905,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -950,7 +951,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1038,6 +1039,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 acc274aaff..0771cb0e82 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14616,6 +14623,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 2421630d43..8af7e0d716 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12471,6 +12478,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 538293f1f5..9c17e68a43 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3981,6 +3982,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 18a8c0b6b0..827b7c74cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 55227cca03..042b93f614 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 986e711348..68000565e2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2735,6 +2735,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3103,6 +3104,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3572,6 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5609fd0344..16d5b2335c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -686,6 +686,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e0903fd5eb..e4874d7519 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 759bf52c7c..32e15a7705 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..3e6be5671f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63b201d111..4976ade348 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7240,8 +7250,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..149f44a281 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -640,6 +640,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 8e7d02ff1b86a956146230b5dd582289bf9ae2b2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 3 Apr 2026 18:42:06 -0700 Subject: [PATCH 022/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 188 +++++++++++++++-- 8 files changed, 527 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 4fd232fe98..83e6676a14 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..d3d1e8777d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4027,47 +4059,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4701,6 +4849,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4860,11 +5009,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4875,10 +5027,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 09129d4b85129f7bf6d40bf8cb80bf5f1df13354 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:54:38 -0700 Subject: [PATCH 023/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 392 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 369 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 61 ++- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 41 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1285 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..d2b42440e0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,173 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + created_at: 455, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + }, + completed_at: 456, + duration_ms: Some(1234), + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +854,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..930ae8bc58 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,116 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = u64::try_from(notification.created_at).ok(); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: u64::try_from(notification.completed_at).unwrap_or_default(), + duration_ms: notification + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +446,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6e288da81e..da38311a1c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -293,6 +295,8 @@ pub(crate) async fn apply_bespoke_event_handling( turn, created_at: set_turn_started_at(&thread_state).await, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -305,7 +309,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1783,7 +1794,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1951,6 +1969,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, started_at_ms: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let completed_at_ms = now_unix_timestamp_millis(); @@ -1966,6 +1985,10 @@ async fn emit_turn_completed_with_status( duration_ms: started_at_ms .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_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; @@ -2109,6 +2132,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2126,6 +2150,7 @@ async fn handle_turn_complete( status, error, started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -2134,6 +2159,7 @@ async fn handle_turn_complete( async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2145,6 +2171,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -3316,6 +3343,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3360,6 +3388,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3404,6 +3433,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3664,7 +3694,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3678,11 +3715,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..18a8c0b6b0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6449,6 +6449,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7221,6 +7230,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..10942469ca 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,21 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?, + ]) .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +109,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +132,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +150,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 3155a4be9c..6ad3d44485 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..986e711348 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2559,6 +2559,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..a5088c4113 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..e0903fd5eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 0e0fbc3c293f8793b70427d43a84bfdca191e8dc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:54:38 -0700 Subject: [PATCH 024/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d2b42440e0..94455d73c4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -173,6 +176,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -223,6 +253,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -292,6 +323,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -887,6 +929,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -928,6 +975,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -947,6 +999,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -975,6 +1028,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -988,6 +1049,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1017,6 +1079,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1044,6 +1107,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1061,6 +1125,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 930ae8bc58..c32d043c90 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,10 +386,23 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -396,6 +413,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -509,6 +527,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -540,6 +559,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index da38311a1c..8f5d65f497 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1364,8 +1364,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2216,6 +2222,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2226,6 +2233,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3560,6 +3572,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3614,6 +3627,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 6ad3d44485..55227cca03 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From faaf46a0713b5c9387a8edb823437ab8191e87be Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:54:38 -0700 Subject: [PATCH 025/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 94455d73c4..4fd232fe98 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -231,7 +232,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -904,7 +905,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -950,7 +951,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1038,6 +1039,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 acc274aaff..0771cb0e82 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14616,6 +14623,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 2421630d43..8af7e0d716 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12471,6 +12478,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 538293f1f5..9c17e68a43 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3981,6 +3982,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 18a8c0b6b0..827b7c74cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 55227cca03..042b93f614 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 986e711348..68000565e2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2735,6 +2735,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3103,6 +3104,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3572,6 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5609fd0344..16d5b2335c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -686,6 +686,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e0903fd5eb..e4874d7519 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 759bf52c7c..32e15a7705 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..3e6be5671f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63b201d111..4976ade348 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7240,8 +7250,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..149f44a281 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -640,6 +640,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 128930a1d69e25485bc3222cecaf608afe44b6d5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:54:38 -0700 Subject: [PATCH 026/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 188 +++++++++++++++-- 8 files changed, 527 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 4fd232fe98..83e6676a14 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..d3d1e8777d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4027,47 +4059,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4701,6 +4849,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4860,11 +5009,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4875,10 +5027,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 830f8efa5f0448e5a81bacbb5b11fcfdf67cc417 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:59:23 -0700 Subject: [PATCH 027/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 392 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 369 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 61 ++- .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1285 insertions(+), 49 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..d2b42440e0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,173 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + }, + created_at: 455, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + }, + completed_at: 456, + duration_ms: Some(1234), + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +854,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..930ae8bc58 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,116 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = u64::try_from(notification.created_at).ok(); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: u64::try_from(notification.completed_at).unwrap_or_default(), + duration_ms: notification + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +446,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6e288da81e..da38311a1c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -293,6 +295,8 @@ pub(crate) async fn apply_bespoke_event_handling( turn, created_at: set_turn_started_at(&thread_state).await, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -305,7 +309,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1783,7 +1794,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1951,6 +1969,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, started_at_ms: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let completed_at_ms = now_unix_timestamp_millis(); @@ -1966,6 +1985,10 @@ async fn emit_turn_completed_with_status( duration_ms: started_at_ms .map(|started_at_ms| completed_at_ms.saturating_sub(started_at_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; @@ -2109,6 +2132,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2126,6 +2150,7 @@ async fn handle_turn_complete( status, error, started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -2134,6 +2159,7 @@ async fn handle_turn_complete( async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2145,6 +2171,7 @@ async fn handle_turn_interrupted( TurnStatus::Interrupted, /*error*/ None, turn_summary.started_at_ms, + analytics_events_client, outgoing, ) .await; @@ -3316,6 +3343,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3360,6 +3388,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3404,6 +3433,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3664,7 +3694,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3678,11 +3715,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + /*analytics_events_client*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..18a8c0b6b0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6449,6 +6449,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7221,6 +7230,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 3155a4be9c..6ad3d44485 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..986e711348 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2559,6 +2559,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..a5088c4113 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..e0903fd5eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 9f40c2751f7fb74b53af96a987291847724ce0b6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:59:27 -0700 Subject: [PATCH 028/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d2b42440e0..94455d73c4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -173,6 +176,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -223,6 +253,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -292,6 +323,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -887,6 +929,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -928,6 +975,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -947,6 +999,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -975,6 +1028,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -988,6 +1049,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1017,6 +1079,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1044,6 +1107,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1061,6 +1125,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 930ae8bc58..c32d043c90 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,10 +386,23 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -396,6 +413,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -509,6 +527,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -540,6 +559,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index da38311a1c..8f5d65f497 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1364,8 +1364,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2216,6 +2222,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2226,6 +2233,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3560,6 +3572,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3614,6 +3627,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 6ad3d44485..55227cca03 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 7039a972089d5fa01047e982d984094be96b659b Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:59:27 -0700 Subject: [PATCH 029/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 94455d73c4..4fd232fe98 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -231,7 +232,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -904,7 +905,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -950,7 +951,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1038,6 +1039,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 acc274aaff..0771cb0e82 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14616,6 +14623,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 2421630d43..8af7e0d716 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12471,6 +12478,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 538293f1f5..9c17e68a43 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3981,6 +3982,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 18a8c0b6b0..827b7c74cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 55227cca03..042b93f614 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 986e711348..68000565e2 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2735,6 +2735,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3103,6 +3104,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3572,6 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5609fd0344..16d5b2335c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -686,6 +686,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e0903fd5eb..e4874d7519 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 759bf52c7c..32e15a7705 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..3e6be5671f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63b201d111..4976ade348 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7240,8 +7250,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..149f44a281 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -640,6 +640,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 2238d006d7b264c64bd436221ce857cacb396bce Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 09:59:27 -0700 Subject: [PATCH 030/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 188 +++++++++++++++-- 8 files changed, 527 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 4fd232fe98..83e6676a14 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -921,7 +927,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -967,7 +973,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -989,6 +995,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1026,6 +1116,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1039,6 +1130,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index c32d043c90..ad2624095e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = u64::try_from(notification.created_at).ok(); } @@ -400,6 +411,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -415,6 +427,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -465,6 +478,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -550,7 +580,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..d3d1e8777d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4027,47 +4059,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4701,6 +4849,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4860,11 +5009,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4875,10 +5027,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From d0b66a403ac18df9dcae86cc19478e74d98291fb Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 11:39:13 -0700 Subject: [PATCH 031/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 4 + .../schema/json/ServerNotification.json | 20 ++++ .../codex_app_server_protocol.schemas.json | 20 ++++ .../codex_app_server_protocol.v2.schemas.json | 20 ++++ .../json/v2/TurnCompletedNotification.json | 14 +++ .../json/v2/TurnStartedNotification.json | 6 + .../v2/TurnCompletedNotification.ts | 10 +- .../typescript/v2/TurnStartedNotification.ts | 6 +- .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 30 +++++ codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 20 ++++ codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 48 files changed, 776 insertions(+), 22 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..7ff1322d72 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1061,6 +1061,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } @@ -1835,6 +1837,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, } ) ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..251327a560 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3566,6 +3566,19 @@ }, "TurnCompletedNotification": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -3574,6 +3587,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -3680,6 +3694,11 @@ }, "TurnStartedNotification": { "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -3688,6 +3707,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 a589903032..acc274aaff 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 @@ -14364,6 +14364,19 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -14372,6 +14385,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -14640,6 +14654,11 @@ "TurnStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -14648,6 +14667,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 f041f8aae8..2421630d43 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 @@ -12219,6 +12219,19 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -12227,6 +12240,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], @@ -12495,6 +12509,11 @@ "TurnStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -12503,6 +12522,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], 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 770cc920cf..3780541077 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1559,6 +1559,19 @@ } }, "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": "integer" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" }, @@ -1567,6 +1580,7 @@ } }, "required": [ + "completedAt", "threadId", "turn" ], 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 761ddc9a62..94af685a0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1559,6 +1559,11 @@ } }, "properties": { + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1567,6 +1572,7 @@ } }, "required": [ + "createdAt", "threadId", "turn" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts index e1b151bfa7..fbe9e11b5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts @@ -3,4 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Turn } from "./Turn"; -export type TurnCompletedNotification = { threadId: string, turn: Turn, }; +export type TurnCompletedNotification = { threadId: string, turn: Turn, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts index 34f71b2465..8c31149506 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Turn } from "./Turn"; -export type TurnStartedNotification = { threadId: string, turn: Turn, }; +export type TurnStartedNotification = { threadId: string, turn: Turn, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt: number, }; 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..761c40c5c2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6446,6 +6446,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6781,6 +6784,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9379,6 +9385,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index e92ef67a7e..51cfb0b700 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -240,6 +240,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -251,6 +252,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -272,6 +275,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1259,6 +1264,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1343,6 +1350,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..6a4d5370bb 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -246,6 +246,8 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }) if turn_id == tc.sub_id )); } @@ -1291,6 +1293,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1308,6 +1311,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1472,6 +1477,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1490,10 +1496,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1512,6 +1521,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1570,6 +1581,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1586,10 +1598,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1601,10 +1616,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1625,6 +1643,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1652,6 +1672,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1668,10 +1689,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1688,10 +1712,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1708,6 +1735,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4577,6 +4606,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a281134e71..34d14df03e 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 123a79ebbc..e8be612e19 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 74b2e471cd..dac552d072 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -449,9 +449,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -526,9 +532,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 7a274d534f..69cdc9d9bc 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -48,6 +48,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 4d4ff383ac..f4aa2d54e0 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -113,6 +113,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..9135fabe5e 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1012,6 +1012,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 62a9aa199f..fad157a49c 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index ee81b961e3..40bfb66d21 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -838,6 +838,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1338,6 +1340,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1364,6 +1368,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1519,6 +1525,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5a396a9810..c3303371ab 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -716,7 +717,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..36cefb3e17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e262b55a31..68289e1fe7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9184,13 +9184,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9201,7 +9207,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10422,6 +10431,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10442,6 +10454,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..36b5687ebc 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..56968c82de 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1211,6 +1211,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 02429e9d9b..b07b23facd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5890,6 +5890,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5910,6 +5913,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 5603659dbda157025135d9ef6b9b1a7920948a37 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 11:39:16 -0700 Subject: [PATCH 032/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..26b1ef943e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +860,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..5c9e1e1d49 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +454,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 761c40c5c2..48d44c35c6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6452,6 +6452,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7227,6 +7236,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 3155a4be9c..6ad3d44485 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6a4d5370bb..e1a94f71ca 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2588,6 +2588,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 9135fabe5e..79d90b3814 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 36cefb3e17..b5c1baf903 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 7d63da2c64cc10b36ef2763493c4f95af846eb09 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 11:39:16 -0700 Subject: [PATCH 033/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 26b1ef943e..f95978f397 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -178,6 +181,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -229,6 +259,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -298,6 +329,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -893,6 +935,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -934,6 +981,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -953,6 +1005,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -981,6 +1034,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -994,6 +1055,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1023,6 +1085,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1050,6 +1113,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1067,6 +1131,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5c9e1e1d49..3a095e23d7 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,6 +386,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -389,6 +394,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -399,6 +416,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -517,6 +535,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -548,6 +567,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 6ad3d44485..55227cca03 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 24ded922f51ab24f0eb4214a7bb76532c0e409cc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 11:39:16 -0700 Subject: [PATCH 034/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f95978f397..8a175dd326 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -237,7 +238,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -910,7 +911,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -956,7 +957,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1044,6 +1045,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 acc274aaff..0771cb0e82 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14616,6 +14623,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 2421630d43..8af7e0d716 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12471,6 +12478,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c1481af11..e3dd115b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3993,6 +3994,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 48d44c35c6..2cad154758 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 55227cca03..042b93f614 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index e1a94f71ca..4615bcfbd7 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2764,6 +2764,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3132,6 +3133,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3601,6 +3603,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c3303371ab..6a8ce741eb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -687,6 +687,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b5c1baf903..82390e606e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 68289e1fe7..f9e3d4bf08 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 56968c82de..7ffc20a26a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b07b23facd..d305580347 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7244,8 +7254,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From f80f096edb17505d9964844233131d578377c08d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 11:39:16 -0700 Subject: [PATCH 035/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 188 +++++++++++++++-- 8 files changed, 527 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 8a175dd326..aa87ca88e5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -927,7 +933,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -973,7 +979,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -995,6 +1001,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1032,6 +1122,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1045,6 +1136,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3a095e23d7..b75548dfe9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -403,6 +414,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -418,6 +430,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -473,6 +486,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -558,7 +588,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..d3d1e8777d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4027,47 +4059,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4701,6 +4849,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4860,11 +5009,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4875,10 +5027,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From ce523846916c5075188ee8a0e79458f97dcd6670 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 15:53:12 -0700 Subject: [PATCH 036/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 4 + .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 30 +++++ codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 20 ++++ codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 43 files changed, 730 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..7ff1322d72 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1061,6 +1061,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } @@ -1835,6 +1837,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, } ) ) 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd474..761c40c5c2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6446,6 +6446,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6781,6 +6784,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9379,6 +9385,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index e92ef67a7e..51cfb0b700 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -240,6 +240,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -251,6 +252,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -272,6 +275,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1259,6 +1264,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1343,6 +1350,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4ff31e8bd0..6a4d5370bb 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -246,6 +246,8 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }) if turn_id == tc.sub_id )); } @@ -1291,6 +1293,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1308,6 +1311,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1472,6 +1477,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1490,10 +1496,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1512,6 +1521,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1570,6 +1581,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1586,10 +1598,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1601,10 +1616,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1625,6 +1643,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1652,6 +1672,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1668,10 +1689,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1688,10 +1712,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1708,6 +1735,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4577,6 +4606,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a281134e71..34d14df03e 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 123a79ebbc..e8be612e19 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 74b2e471cd..dac552d072 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -449,9 +449,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -526,9 +532,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 7a274d534f..69cdc9d9bc 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -48,6 +48,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 4d4ff383ac..f4aa2d54e0 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -113,6 +113,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 94a81d6209..9135fabe5e 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1012,6 +1012,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 62a9aa199f..fad157a49c 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index ee81b961e3..40bfb66d21 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -838,6 +838,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1338,6 +1340,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1364,6 +1368,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1519,6 +1525,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5a396a9810..c3303371ab 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -716,7 +717,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..36cefb3e17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e262b55a31..68289e1fe7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9184,13 +9184,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9201,7 +9207,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10422,6 +10431,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10442,6 +10454,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..36b5687ebc 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c0be4a94f6..56968c82de 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1211,6 +1211,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 02429e9d9b..b07b23facd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5890,6 +5890,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5910,6 +5913,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From d7dadd4e6f3322753f6639f16a810fdc55679822 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 15:53:13 -0700 Subject: [PATCH 037/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d51112a8bd..26b1ef943e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -25,28 +26,43 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -111,6 +127,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -671,6 +860,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b8a543a3b2..a4dc58de36 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -12,9 +12,13 @@ use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -153,6 +157,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -171,6 +183,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -220,6 +238,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 36efb01a69..fcb3d4d09f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,9 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -35,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -120,6 +124,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 31b8516e85..1c14135581 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use serde::Serialize; use std::path::PathBuf; @@ -29,6 +37,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -75,6 +118,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6f927d09c0..f74b9dc716 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,6 +9,9 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d83cddcbbc..5c9e1e1d49 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -24,11 +26,22 @@ use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -37,6 +50,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -45,6 +60,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -64,18 +105,25 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -120,6 +168,52 @@ impl AnalyticsReducer { ); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -220,24 +314,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -260,6 +454,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 761c40c5c2..48d44c35c6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6452,6 +6452,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7227,6 +7236,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c63d0a83cf..37dd59a18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 3155a4be9c..6ad3d44485 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73f5c6ed7d..1fa969ed84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -183,6 +184,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -426,6 +428,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -480,6 +483,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -663,6 +667,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -800,6 +805,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1485,6 +1501,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1877,11 +1894,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2192,6 +2211,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -5918,6 +5942,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6203,6 +6229,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6bb8257f09..1fadfdca63 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -94,6 +94,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6a4d5370bb..e1a94f71ca 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2588,6 +2588,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 9135fabe5e..79d90b3814 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -21,6 +21,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -207,6 +208,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -218,6 +220,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -256,6 +276,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -325,6 +346,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -869,6 +891,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 36cefb3e17..b5c1baf903 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From c6da10659c9f012315585c312547a2b605b74352 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 15:53:13 -0700 Subject: [PATCH 038/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 26b1ef943e..f95978f397 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -46,6 +46,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -178,6 +181,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -229,6 +259,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -298,6 +329,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -893,6 +935,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -934,6 +981,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -953,6 +1005,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -981,6 +1034,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -994,6 +1055,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1023,6 +1085,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1050,6 +1113,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1067,6 +1131,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fcb3d4d09f..3ae13b0a53 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -154,6 +154,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5c9e1e1d49..3a095e23d7 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -34,6 +34,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -83,6 +84,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -206,6 +208,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -358,6 +361,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -382,6 +386,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -389,6 +394,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -399,6 +416,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -517,6 +535,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -548,6 +567,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 6ad3d44485..55227cca03 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From b50e2b984422b66cb3aaca4648255fcd527bc5fc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 15:53:13 -0700 Subject: [PATCH 039/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f95978f397..8a175dd326 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -28,6 +28,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -237,7 +238,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -910,7 +911,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -956,7 +957,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1044,6 +1045,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c1481af11..e3dd115b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3993,6 +3994,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 48d44c35c6..2cad154758 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6429,9 +6429,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 55227cca03..042b93f614 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1fa969ed84..69dbded68b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -54,6 +54,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -114,6 +115,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -877,6 +879,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -987,6 +990,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1072,6 +1076,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1240,6 +1251,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, } impl Session { @@ -1392,6 +1404,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1455,6 +1468,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2447,6 +2461,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2457,6 +2472,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2522,6 +2538,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2634,6 +2651,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4522,7 +4540,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4780,6 +4798,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4805,9 +4824,22 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5620,6 +5652,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6249,7 +6282,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index e1a94f71ca..4615bcfbd7 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2764,6 +2764,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3132,6 +3133,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3601,6 +3603,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 2a63e5b547..2dd5cff883 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a347488c2d..2705782bcb 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -738,6 +738,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 231ab1a5dc..0ca06c9da6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1562,6 +1562,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1676,6 +1677,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 35fe9d07e7..f63a0be647 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1667,6 +1667,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1758,6 +1759,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1783,6 +1785,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1894,6 +1897,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1943,6 +1947,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3148,6 +3153,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3173,6 +3179,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4cb84e8b66..8551b7131b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 0e043bd9f6..19e9b3f0fc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -104,6 +104,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 22870cae73..7b2afe9301 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bcd068614..22585eb898 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index c0776e1ea7..cbdee255c0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -181,6 +181,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -244,6 +245,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -404,6 +406,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -623,6 +626,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4262a6aab0..17ec92debe 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index e7f33df148..79f00a3961 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -164,6 +164,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c3303371ab..6a8ce741eb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -687,6 +687,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b5c1baf903..82390e606e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 68289e1fe7..f9e3d4bf08 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2229,6 +2229,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2311,6 +2312,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 56968c82de..7ffc20a26a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -408,6 +409,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -426,6 +428,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b07b23facd..d305580347 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5568,6 +5569,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5783,6 +5792,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7244,8 +7254,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 98c5dd3010316733ccf8737fe5f8d25dfafe2fa6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 15:53:13 -0700 Subject: [PATCH 040/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../schema/json/ServerNotification.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 188 +++++++++++++++-- 23 files changed, 876 insertions(+), 49 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 8a175dd326..aa87ca88e5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,10 +15,12 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -28,6 +31,9 @@ use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -927,7 +933,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -973,7 +979,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -995,6 +1001,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1032,6 +1122,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1045,6 +1136,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index a4dc58de36..6fb61d3946 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -13,6 +14,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -189,6 +191,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3ae13b0a53..73b05efc01 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,8 +1,11 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -39,6 +42,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -170,6 +174,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -261,6 +283,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1c14135581..67b935d3ca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -72,6 +72,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -119,6 +146,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -126,6 +154,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f74b9dc716..49fcf34f80 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,11 +6,14 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3a095e23d7..b75548dfe9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -28,6 +30,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -86,6 +90,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -126,6 +131,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -210,6 +218,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -363,6 +372,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -388,6 +398,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -403,6 +414,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -418,6 +430,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -473,6 +486,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -558,7 +588,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..1c8dd0657c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt?: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt?: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs?: number | null, }; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69dbded68b..d3d1e8777d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::agent::AgentControl; use crate::agent::AgentStatus; @@ -52,8 +54,12 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -237,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4027,47 +4059,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4701,6 +4849,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4860,11 +5009,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4875,10 +5027,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 7c482f2179d8c38f9b5bec6a9a77901a7ee1cefe Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:02:18 -0700 Subject: [PATCH 041/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 4 + .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 30 +++++ codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 20 ++++ codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 43 files changed, 730 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..7ff1322d72 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1061,6 +1061,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } @@ -1835,6 +1837,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, } ) ) 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..9ee8b6a585 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -247,6 +247,8 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }) if turn_id == tc.sub_id )); } @@ -1307,6 +1309,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1327,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1493,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1512,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1537,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1597,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1614,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1632,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1659,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1688,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1705,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1728,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1751,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4660,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..36cefb3e17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..36b5687ebc 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From ca49436930dcb2d03b6a99a9ca390d57c0edeeec Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:04:27 -0700 Subject: [PATCH 042/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..98180785dc 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..4daa221e02 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..2bcdfea88c 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 9ee8b6a585..755d9bbcce 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2623,6 +2623,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 36cefb3e17..b5c1baf903 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 94619576863d7c1d2c86f6120f4c75fbf683e361 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:04:27 -0700 Subject: [PATCH 043/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 98180785dc..13d3c93052 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4daa221e02..730c728f94 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 2bcdfea88c..71111a2cd4 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From cfa70f060e328bbce845ecd6a2d7103b296695a9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:05:47 -0700 Subject: [PATCH 044/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 13d3c93052..35fc7944ec 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c1481af11..e3dd115b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3993,6 +3994,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 755d9bbcce..2a774cc531 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2803,6 +2803,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3171,6 +3172,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3643,6 +3645,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b5c1baf903..82390e606e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From ee523a3857581392a709ad6601783cf9171dd000 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:07:08 -0700 Subject: [PATCH 045/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../schema/json/ServerNotification.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 23 files changed, 874 insertions(+), 49 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 35fc7944ec..a262c6d840 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 730c728f94..0c4f8b5237 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 71111a2cd4..aff664bb76 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..1c8dd0657c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt?: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt?: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs?: number | null, }; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 466fcf6e49083fd55ff26df4d4ffdeb9fa1a8d67 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:24 -0700 Subject: [PATCH 046/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 4 + .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 30 +++++ codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 43 files changed, 733 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..7ff1322d72 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1061,6 +1061,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, }) } @@ -1835,6 +1837,8 @@ mod tests { status: codex_app_server_protocol::TurnStatus::Completed, error: None, }, + completed_at: 0, + duration_ms: None, } ) ) 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..9ee8b6a585 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -247,6 +247,8 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }) if turn_id == tc.sub_id )); } @@ -1307,6 +1309,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1327,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1493,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1512,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1537,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1597,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1614,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1632,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1659,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1688,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1705,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1728,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1751,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4660,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..36b5687ebc 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 60014ca99222d1a895dd01b5349bdef0c3ef0924 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 047/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..98180785dc 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..4daa221e02 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..2bcdfea88c 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 9ee8b6a585..755d9bbcce 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2623,6 +2623,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7dc0e253a7..873e157b1c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 6197512200982a8e8e2379eb6d73a8d40cb33da6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 048/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 98180785dc..13d3c93052 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4daa221e02..730c728f94 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 2bcdfea88c..71111a2cd4 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From eb1c895336311c415938286d82793efd23431824 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 049/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 13d3c93052..35fc7944ec 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c1481af11..e3dd115b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3993,6 +3994,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 755d9bbcce..2a774cc531 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2803,6 +2803,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3171,6 +3172,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3643,6 +3645,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 873e157b1c..4789399cca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 0c1d75ce3bf979ce3d6c017de52cd539b09419c0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 050/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../schema/json/ServerNotification.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 23 files changed, 874 insertions(+), 49 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 35fc7944ec..a262c6d840 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 730c728f94..0c4f8b5237 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 71111a2cd4..aff664bb76 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..1c8dd0657c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt?: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt?: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs?: number | null, }; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 8111bfb65a548d5f3eeb05e3d4f7c2b948617176 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:24 -0700 Subject: [PATCH 051/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 30 +++++ codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 43 files changed, 735 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..38418cf1ea 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..9ee8b6a585 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -247,6 +247,8 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }) if turn_id == tc.sub_id )); } @@ -1307,6 +1309,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1327,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1493,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1512,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1537,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1597,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1614,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1632,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1659,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1688,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1705,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1728,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1751,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4660,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..36b5687ebc 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 0c5232ef798e8cd90fcb010d458ab7f7bed40540 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 052/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..98180785dc 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: Some("codex-tui".to_string()), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..4daa221e02 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: Option, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..2bcdfea88c 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: Some(product_client_id), + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 9ee8b6a585..755d9bbcce 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2623,6 +2623,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7dc0e253a7..873e157b1c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From c7a5aaf45c140d778092fe63e9fb052eb8dc556e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 053/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 98180785dc..13d3c93052 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4daa221e02..730c728f94 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 2bcdfea88c..71111a2cd4 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 4fdeb488a3967f95b7730a3dc74f54920aff4e7a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 054/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 13d3c93052..35fc7944ec 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: Some("codex-tui".to_string()), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c1481af11..e3dd115b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3993,6 +3994,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 755d9bbcce..2a774cc531 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2803,6 +2803,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3171,6 +3172,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3643,6 +3645,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 873e157b1c..4789399cca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 53c3233677c792035234d9e3e09c7b3b0451ce81 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:25 -0700 Subject: [PATCH 055/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../schema/json/ServerNotification.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 23 files changed, 874 insertions(+), 49 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 35fc7944ec..a262c6d840 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 730c728f94..0c4f8b5237 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: Option, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: Some(originator().value), + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 71111a2cd4..aff664bb76 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..1c8dd0657c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt?: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt?: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs?: number | null, }; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 86a7654ab975aa536eaefc1b17889098364a351c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:24 -0700 Subject: [PATCH 056/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 30 +++++ codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 43 files changed, 735 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..38418cf1ea 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..9ee8b6a585 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -247,6 +247,8 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }) if turn_id == tc.sub_id )); } @@ -1307,6 +1309,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1327,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1493,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1512,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1537,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1597,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1614,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1632,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1659,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1688,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1705,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1728,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1751,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4660,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..de540d615c 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: notification.turn.created_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From e8e56aa9496283c10b08d9e8208edc6bf3518e27 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 057/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..caf6e0a977 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..deafeb6b57 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..3aeb001b30 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 9ee8b6a585..755d9bbcce 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2623,6 +2623,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7dc0e253a7..873e157b1c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 48ff03e97e3152af16ac129c11689d6a47400e2a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 058/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index caf6e0a977..22b3638403 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index deafeb6b57..cf93bf86cf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3aeb001b30..31585774e3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 0d0e0c3378f5048ab7aec5931b0d7cccc3255bac Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 059/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 22b3638403..c7449aac41 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c1481af11..e3dd115b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3993,6 +3994,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 755d9bbcce..2a774cc531 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2803,6 +2803,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3171,6 +3172,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3643,6 +3645,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 873e157b1c..4789399cca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 51d4344f2358b8815dda7652a60e150739b20b74 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 060/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../schema/json/ServerNotification.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 23 files changed, 874 insertions(+), 49 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c7449aac41..5dfbc3428c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cf93bf86cf..03128c4a25 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 31585774e3..3ef3a1e91d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..1c8dd0657c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt?: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt?: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs?: number | null, }; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 73cdd364984e41975d0c358192475ace49347cf7 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 16:13:24 -0700 Subject: [PATCH 061/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++++-- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 43 files changed, 746 insertions(+), 28 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..38418cf1ea 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..14464cf134 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..de540d615c 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: notification.turn.created_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 744310afcd150f512ee12eaa387bd47112c9c52f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 062/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..caf6e0a977 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..deafeb6b57 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..3aeb001b30 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 14464cf134..4afa897d1b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2627,6 +2627,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7dc0e253a7..873e157b1c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 5d1e1f8b287a192c7846546dc43e92aecf91707c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 063/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index caf6e0a977..22b3638403 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index deafeb6b57..cf93bf86cf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3aeb001b30..31585774e3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 902d44e832d1c98fdcec23c808544b1061b0c929 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 064/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 22b3638403..c7449aac41 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1c1481af11..e3dd115b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3993,6 +3994,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4afa897d1b..567e43a871 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2807,6 +2807,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3175,6 +3176,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3647,6 +3649,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 873e157b1c..4789399cca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 69a26ef398b3bcbf1434a3f17b378218fa25a7e6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 17:52:16 -0700 Subject: [PATCH 065/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../schema/json/ServerNotification.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 23 files changed, 874 insertions(+), 49 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c7449aac41..5dfbc3428c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cf93bf86cf..03128c4a25 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 31585774e3..3ef3a1e91d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..1c8dd0657c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt?: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt?: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs?: number | null, }; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From ea0620c4289dd4441a133cc187db59d68d8ac911 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 20:35:09 -0700 Subject: [PATCH 066/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 12 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++++-- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 43 files changed, 746 insertions(+), 28 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..38418cf1ea 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..1c1481af11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,18 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..14464cf134 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..de540d615c 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: notification.turn.created_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 88730f674914d291a6b94f324facb492c7f5bafa Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 20:50:31 -0700 Subject: [PATCH 067/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 ++++ .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../schema/json/v2/ReviewStartResponse.json | 24 ++++ .../schema/json/v2/ThreadForkResponse.json | 24 ++++ .../schema/json/v2/ThreadListResponse.json | 24 ++++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 ++++ .../schema/json/v2/ThreadReadResponse.json | 24 ++++ .../schema/json/v2/ThreadResumeResponse.json | 24 ++++ .../json/v2/ThreadRollbackResponse.json | 24 ++++ .../schema/json/v2/ThreadStartResponse.json | 24 ++++ .../json/v2/ThreadStartedNotification.json | 24 ++++ .../json/v2/ThreadUnarchiveResponse.json | 24 ++++ .../json/v2/TurnCompletedNotification.json | 24 ++++ .../schema/json/v2/TurnStartResponse.json | 24 ++++ .../json/v2/TurnStartedNotification.json | 24 ++++ .../schema/typescript/v2/Turn.ts | 14 ++- .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 9 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++++-- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 58 files changed, 1092 insertions(+), 29 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..38418cf1ea 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..c2840a6807 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..930952ead3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..14464cf134 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..de540d615c 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: notification.turn.created_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From d3d94c376cf160fa1b66baf18c76581bab2a6bda Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 20:50:33 -0700 Subject: [PATCH 068/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..caf6e0a977 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..deafeb6b57 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..3aeb001b30 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 14464cf134..4afa897d1b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2627,6 +2627,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7dc0e253a7..873e157b1c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 44463cb72b26d1ec5e9d07352abe9a81b42f3924 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 20:50:33 -0700 Subject: [PATCH 069/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index caf6e0a977..22b3638403 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index deafeb6b57..cf93bf86cf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3aeb001b30..31585774e3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 49b920cf4a882126ee29f9dff67f59ed6150fcc6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 20:50:33 -0700 Subject: [PATCH 070/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 22b3638403..c7449aac41 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 930952ead3..7f8367fbee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3990,6 +3991,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4afa897d1b..567e43a871 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2807,6 +2807,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3175,6 +3176,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3647,6 +3649,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 873e157b1c..4789399cca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 0abae510ba089c0dc9b008c42218be840f56fa0c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 4 Apr 2026 20:50:33 -0700 Subject: [PATCH 071/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c7449aac41..5dfbc3428c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cf93bf86cf..03128c4a25 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 31585774e3..3ef3a1e91d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 7757cb98805e9c5c8df08158d271597b8c20357f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 15:12:13 -0700 Subject: [PATCH 072/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 8 files changed, 239 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5dfbc3428c..1f50e2edbf 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 03128c4a25..a29164c698 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3ef3a1e91d..978f148ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 567e43a871..5f2b827d6c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1878,6 +1879,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2329,6 +2332,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2697,6 +2702,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3539,6 +3545,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From e6cb48299315af9aee2c8bb8e664f9d5ac70ab58 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 15:52:37 -0700 Subject: [PATCH 073/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 9 files changed, 240 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5dfbc3428c..1f50e2edbf 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 03128c4a25..a29164c698 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3ef3a1e91d..978f148ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2b78de8dd9..a96b38602e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9094,6 +9094,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 567e43a871..5f2b827d6c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1878,6 +1879,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2329,6 +2332,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2697,6 +2702,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3539,6 +3545,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 9098a3fb8c0fd304ce24230c9609ed145495b9ef Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 13:19:00 -0700 Subject: [PATCH 074/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 ++++ .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../schema/json/v2/ReviewStartResponse.json | 24 ++++ .../schema/json/v2/ThreadForkResponse.json | 24 ++++ .../schema/json/v2/ThreadListResponse.json | 24 ++++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 ++++ .../schema/json/v2/ThreadReadResponse.json | 24 ++++ .../schema/json/v2/ThreadResumeResponse.json | 24 ++++ .../json/v2/ThreadRollbackResponse.json | 24 ++++ .../schema/json/v2/ThreadStartResponse.json | 24 ++++ .../json/v2/ThreadStartedNotification.json | 24 ++++ .../json/v2/ThreadUnarchiveResponse.json | 24 ++++ .../json/v2/TurnCompletedNotification.json | 24 ++++ .../schema/json/v2/TurnStartResponse.json | 24 ++++ .../json/v2/TurnStartedNotification.json | 24 ++++ .../schema/typescript/v2/Turn.ts | 14 ++- .../src/protocol/thread_history.rs | 113 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 9 ++ .../app-server/src/bespoke_event_handling.rs | 103 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++++-- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 58 files changed, 1092 insertions(+), 29 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..38418cf1ea 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..c2840a6807 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..930952ead3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..006b0a8dd8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, status: TurnStatus, error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1956,6 +1981,9 @@ async fn emit_turn_completed_with_status( items: vec![], error, status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + status, + error, + TurnCompletionMetadata { + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, TurnStatus::Interrupted, /*error*/ None, + TurnCompletionMetadata { + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..14464cf134 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..de540d615c 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: notification.turn.created_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 4570e5d894c46a7aaa875f15991db2f79d9f1ec1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 075/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..caf6e0a977 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..deafeb6b57 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..3aeb001b30 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 006b0a8dd8..b53a4e82f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ async fn emit_turn_completed_with_status( status: TurnStatus, error: Option, turn_completion_metadata: TurnCompletionMetadata, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 14464cf134..4afa897d1b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2627,6 +2627,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7dc0e253a7..873e157b1c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 5faf7cfe4f89bc9db825aa272ef0d2229b72c0ee Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 076/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index caf6e0a977..22b3638403 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index deafeb6b57..cf93bf86cf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3aeb001b30..31585774e3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b53a4e82f3..9f8ecde6fb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From ad50902d2c8ab64986c8fc35683dbeae9306af3d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 077/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 22b3638403..c7449aac41 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 930952ead3..7f8367fbee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3990,6 +3991,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4afa897d1b..567e43a871 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2807,6 +2807,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3175,6 +3176,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3647,6 +3649,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 873e157b1c..4789399cca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 9b94ed739500b480b8df2ad22c3ab2f680ff6c92 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 078/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c7449aac41..5dfbc3428c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cf93bf86cf..03128c4a25 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 31585774e3..3ef3a1e91d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 01aa292c4f32b386beab84f6e0c1986e29ca24f5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 079/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 5 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 245 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5dfbc3428c..1f50e2edbf 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 03128c4a25..a29164c698 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3ef3a1e91d..978f148ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2b78de8dd9..a96b38602e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9094,6 +9094,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 965f8a9e11..c87f8113f1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!(event["event_params"]["subagent_source"], serde_json::Value::Null); + assert_eq!(event["event_params"]["parent_thread_id"], serde_json::Value::Null); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 567e43a871..5f2b827d6c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1878,6 +1879,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2329,6 +2332,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2697,6 +2702,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3539,6 +3545,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 8d1b68fce5b5ac425c7ec6d4dbdc538c851c2553 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 09:47:02 -0700 Subject: [PATCH 080/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5dfbc3428c..1f50e2edbf 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 03128c4a25..a29164c698 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3ef3a1e91d..978f148ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2b78de8dd9..a96b38602e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9094,6 +9094,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 965f8a9e11..bd7ecdd218 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 567e43a871..5f2b827d6c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1878,6 +1879,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2329,6 +2332,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2697,6 +2702,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3539,6 +3545,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 577abcf681209d6ab6c74e2c1fbb944e89c4be50 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 13:19:00 -0700 Subject: [PATCH 081/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 ++++ .../codex_app_server_protocol.schemas.json | 24 ++++ .../codex_app_server_protocol.v2.schemas.json | 24 ++++ .../schema/json/v2/ReviewStartResponse.json | 24 ++++ .../schema/json/v2/ThreadForkResponse.json | 24 ++++ .../schema/json/v2/ThreadListResponse.json | 24 ++++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 ++++ .../schema/json/v2/ThreadReadResponse.json | 24 ++++ .../schema/json/v2/ThreadResumeResponse.json | 24 ++++ .../json/v2/ThreadRollbackResponse.json | 24 ++++ .../schema/json/v2/ThreadStartResponse.json | 24 ++++ .../json/v2/ThreadStartedNotification.json | 24 ++++ .../json/v2/ThreadUnarchiveResponse.json | 24 ++++ .../json/v2/TurnCompletedNotification.json | 24 ++++ .../schema/json/v2/TurnStartResponse.json | 24 ++++ .../json/v2/TurnStartedNotification.json | 24 ++++ .../schema/typescript/v2/Turn.ts | 14 ++- .../src/protocol/thread_history.rs | 113 +++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 9 ++ .../app-server/src/bespoke_event_handling.rs | 115 +++++++++++++++--- .../app-server/src/codex_message_processor.rs | 7 ++ codex-rs/app-server/src/in_process.rs | 3 + .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++++-- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 ++ codex-rs/core/src/turn_timing.rs | 24 ++++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 +++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 +++++ codex-rs/protocol/src/protocol.rs | 24 +++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 +++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 +++ .../tui/src/chatwidget/tests/review_mode.rs | 21 ++++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 +++ 58 files changed, 1098 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..38418cf1ea 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..773b536880 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 a589903032..a773a069f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f041f8aae8..d9303ceb58 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 2e0c3605e7..8c740ee7af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88448a1658..b6ec6e1b4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 f26bd03a34..2f24d72bef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 88c8e688df..5d7adc441a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 8453207380..2d77b0f203 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 e21f253b72..c5dd0e10a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 d719ba7d8f..3f6fba4be2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 27a8cdd6bf..62a9f33589 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 c202363e3b..ed2e25f7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 542aea1765..79836839c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 770cc920cf..7ca1e73f77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 7f1c3e4948..1e79af9fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { 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 761ddc9a62..1b9bad3350 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,30 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..c2840a6807 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +createdAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d687..2463a2d14c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_created_at(payload.created_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + created_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + created_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_created_at(mut self, created_at: Option) -> Self { + self.created_at = created_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + created_at: value.created_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..930952ead3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub created_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..8ffc96743c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + created_at: payload.created_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,17 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1954,8 +1979,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + created_at: None, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2129,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2140,38 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2946,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3284,6 +3347,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3328,6 +3392,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3437,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3698,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3719,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..867f48d266 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + created_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..b7e4afc219 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); 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 83ed147e12..6e28dd330b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..14464cf134 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..42f8a0be02 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..06e4cf6cf0 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..61ff1d8d8e 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + created_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..5b62887e2c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + created_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..1b0694e642 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..e6864bbe78 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..b2f6a7587d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + created_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..702dc49ed3 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..533173a1fa 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7dc0e253a7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub created_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..26a636501f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + created_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + created_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..de540d615c 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + created_at: notification.turn.created_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + created_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..e31f9e3ce2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d34bb76fd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..85f6e142fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + created_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + created_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..6397fae740 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..4d9e4d9a63 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..8c7695e234 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..7af0496835 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..b33a8350b9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + created_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + created_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..e26ea2a61b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..d710b7a440 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..9147b02047 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From ff81c834f95e12db94e50b9ac4fcedf0cb25533c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 082/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..caf6e0a977 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + created_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + created_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + created_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_created_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["created_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..deafeb6b57 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) created_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..3aeb001b30 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + created_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + created_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + created_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + created_at: None, + completed: None, + }); + turn_state.created_at = notification + .turn + .created_at + .and_then(|created_at| u64::try_from(created_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + created_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let created_at = turn_state.created_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + created_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8ffc96743c..38b41187e8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1972,6 +1978,7 @@ 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 { @@ -1986,6 +1993,10 @@ 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; @@ -2130,6 +2141,7 @@ 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>, ) { @@ -2149,6 +2161,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2158,6 +2171,7 @@ 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>, ) { @@ -2172,6 +2186,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3348,6 +3363,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3393,6 +3409,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3438,6 +3455,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3702,6 +3720,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3723,6 +3742,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3734,6 +3754,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 867f48d266..1c6c55ce91 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..0be628fdd0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 14464cf134..4afa897d1b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2627,6 +2627,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7dc0e253a7..873e157b1c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 204abb4d5cd523f132184db8420455b8440d1294 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 083/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index caf6e0a977..22b3638403 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), created_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_created_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["created_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index deafeb6b57..cf93bf86cf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) created_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3aeb001b30..31585774e3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, created_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.created_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .created_at .and_then(|created_at| u64::try_from(created_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + created_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, created_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, created_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 38b41187e8..13fde473e8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2217,6 +2223,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2227,6 +2234,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3582,6 +3594,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3636,6 +3649,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0be628fdd0..2867f4aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 17575bdcb59197cdf0bdd0e6791fe0a41ed301e0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 084/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 22b3638403..c7449aac41 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a773a069f4..32166d2f71 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 d9303ceb58..91864c58b9 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 930952ead3..7f8367fbee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3990,6 +3991,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c6c55ce91..2b78de8dd9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 2867f4aa76..965f8a9e11 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["created_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4afa897d1b..567e43a871 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2807,6 +2807,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3175,6 +3176,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3647,6 +3649,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b2f6a7587d..c84631b67f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 873e157b1c..4789399cca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 26a636501f..199ecf3038 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d34bb76fd0..cc834a1233 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85f6e142fe..c02db2f5ae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From aa6b3938e4e07ee70a2af2fa05b84f26fd27036a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 5 Apr 2026 16:08:28 -0700 Subject: [PATCH 085/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c7449aac41..5dfbc3428c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["created_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cf93bf86cf..03128c4a25 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 31585774e3..3ef3a1e91d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { created_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.created_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { created_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 065c8402991daa5f9cafc1cd0675df6af03909ad Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 09:47:02 -0700 Subject: [PATCH 086/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5dfbc3428c..1f50e2edbf 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["created_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 03128c4a25..a29164c698 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3ef3a1e91d..978f148ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2b78de8dd9..a96b38602e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9094,6 +9094,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 965f8a9e11..bd7ecdd218 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 567e43a871..5f2b827d6c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1878,6 +1879,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2329,6 +2332,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2697,6 +2702,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3539,6 +3545,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From bbb42eb94f49d5ab9f8020557e979e8e29c68ab2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 087/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 113 +++++++++++++- .../app-server-protocol/src/protocol/v2.rs | 9 ++ .../app-server/src/bespoke_event_handling.rs | 143 ++++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 + codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 10 ++ .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 +++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 59 files changed, 1136 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..5219ccf691 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d687..bf9bb4a091 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..3c32f190f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..07858ce873 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1954,8 +1980,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2098,12 +2127,22 @@ async fn find_and_remove_turn_summary( std::mem::take(&mut state.turn_summary) } +async fn take_terminal_turn_snapshot( + _conversation_id: ThreadId, + thread_state: &Arc>, +) -> Option { + let mut state = thread_state.lock().await; + state.take_terminal_turn_snapshot() +} + async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { + let turn_snapshot = take_terminal_turn_snapshot(conversation_id, thread_state).await; let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; let (status, error) = match turn_summary.last_error { @@ -2111,22 +2150,41 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_snapshot.and_then(|turn| turn.started_at), + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_snapshot = take_terminal_turn_snapshot(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_snapshot.and_then(|turn| turn.started_at), + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2959,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3280,10 +3356,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3295,6 +3386,7 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); } other => bail!("unexpected message: {other:?}"), } @@ -3328,6 +3420,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3465,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3726,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3747,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..bc782e19e6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..441e632213 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index be5478dd51..0ad6056110 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -59,6 +59,7 @@ pub(crate) struct ThreadState { pub(crate) listener_generation: u64, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, + completed_turn_snapshot: Option, listener_thread: Option>, } @@ -91,6 +92,7 @@ impl ThreadState { } self.listener_command_tx = None; self.current_turn_history.reset(); + self.completed_turn_snapshot = None; self.listener_thread = None; } @@ -108,9 +110,17 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } + pub(crate) fn take_terminal_turn_snapshot(&mut self) -> Option { + self.completed_turn_snapshot + .take() + .or_else(|| self.active_turn_snapshot()) + } + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + self.completed_turn_snapshot = None; self.current_turn_history.handle_event(event); if !self.current_turn_history.has_active_turn() { + self.completed_turn_snapshot = self.current_turn_history.active_turn_snapshot(); self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..94ea2e2a21 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..992ca9870e 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..14464cf134 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + created_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3f71c69bc3 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + created_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f58a611d33 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + created_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..47df7199f0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + started_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..0484808ad9 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; 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 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..6eabdde6ad 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..58787cfea2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..5d854b73fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..7f42414a86 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 389ad76a246e0e522dabad0b4e78cd1afb97b188 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 088/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 07858ce873..de45b7ae5f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1973,6 +1979,7 @@ 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 { @@ -1987,6 +1994,10 @@ 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; @@ -2139,6 +2150,7 @@ 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>, ) { @@ -2160,6 +2172,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2169,6 +2182,7 @@ 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>, ) { @@ -2185,6 +2199,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3375,6 +3390,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3421,6 +3437,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3466,6 +3483,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3730,6 +3748,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3751,6 +3770,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3762,6 +3782,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index bc782e19e6..3526e7c0d0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 14464cf134..4afa897d1b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2627,6 +2627,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 0d9875fe53b7f07694e909372822a6429b73b8b6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 089/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index de45b7ae5f..acf908c5b1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2230,6 +2236,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2240,6 +2247,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3610,6 +3622,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3664,6 +3677,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From c1b77ebb69c63063aa041cddcbf840cdf93a78f4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 090/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3c32f190f5..4fcc67b054 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3990,6 +3991,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3526e7c0d0..58529a1170 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4afa897d1b..567e43a871 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2807,6 +2807,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3175,6 +3176,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3647,6 +3649,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 47df7199f0..925464d4ce 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6eabdde6ad..d6b2fd6c55 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5d854b73fe..b1bbf02750 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From d256ae0602d707c577aa60ce17b77cee337c5960 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 091/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 1865f543ab92381c3b3420df722dd05f30a9c886 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 092/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 58529a1170..0e4e25d261 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9094,6 +9094,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 567e43a871..5f2b827d6c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1878,6 +1879,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2329,6 +2332,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2697,6 +2702,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3539,6 +3545,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 12d351fff98c3a30e8eb0338be628ce29621bcb6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 093/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 113 +++++++++++++- .../app-server-protocol/src/protocol/v2.rs | 9 ++ .../app-server/src/bespoke_event_handling.rs | 143 ++++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 + codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 10 ++ .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 +++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 19 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 59 files changed, 1136 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..5219ccf691 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d687..bf9bb4a091 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -891,22 +891,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +922,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +942,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +952,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -991,6 +1005,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1168,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1191,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1180,6 +1205,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1219,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1373,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1394,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1448,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1468,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1481,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1573,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1772,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1791,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1829,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1935,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1995,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2083,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2096,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2133,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2172,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2185,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2222,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2256,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2320,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2396,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2414,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2425,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2477,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2504,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2515,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2527,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2747,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2760,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2781,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2800,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2819,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2856,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2870,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2902,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2918,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..3c32f190f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..07858ce873 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -284,6 +286,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +300,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1792,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1961,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1954,8 +1980,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2098,12 +2127,22 @@ async fn find_and_remove_turn_summary( std::mem::take(&mut state.turn_summary) } +async fn take_terminal_turn_snapshot( + _conversation_id: ThreadId, + thread_state: &Arc>, +) -> Option { + let mut state = thread_state.lock().await; + state.take_terminal_turn_snapshot() +} + async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { + let turn_snapshot = take_terminal_turn_snapshot(conversation_id, thread_state).await; let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; let (status, error) = match turn_summary.last_error { @@ -2111,22 +2150,41 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_snapshot.and_then(|turn| turn.started_at), + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_snapshot = take_terminal_turn_snapshot(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_snapshot.and_then(|turn| turn.started_at), + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2901,6 +2959,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3280,10 +3356,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3295,6 +3386,7 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); } other => bail!("unexpected message: {other:?}"), } @@ -3328,6 +3420,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3372,6 +3465,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3632,7 +3726,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3747,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..bc782e19e6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9578,6 +9584,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..441e632213 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index be5478dd51..0ad6056110 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -59,6 +59,7 @@ pub(crate) struct ThreadState { pub(crate) listener_generation: u64, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, + completed_turn_snapshot: Option, listener_thread: Option>, } @@ -91,6 +92,7 @@ impl ThreadState { } self.listener_command_tx = None; self.current_turn_history.reset(); + self.completed_turn_snapshot = None; self.listener_thread = None; } @@ -108,9 +110,17 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } + pub(crate) fn take_terminal_turn_snapshot(&mut self) -> Option { + self.completed_turn_snapshot + .take() + .or_else(|| self.active_turn_snapshot()) + } + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + self.completed_turn_snapshot = None; self.current_turn_history.handle_event(event); if !self.current_turn_history.has_active_turn() { + self.completed_turn_snapshot = self.current_turn_history.active_turn_snapshot(); self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..76a005619e 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..28015b6a53 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..47df7199f0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + started_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..0484808ad9 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; 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 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..6eabdde6ad 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,10 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10433,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10456,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..58787cfea2 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..5d854b73fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..7f42414a86 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 9743b13634e04d4e2591d6687cbf5dc7a3644e55 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 094/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 07858ce873..de45b7ae5f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; 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; @@ -260,6 +261,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -295,6 +297,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -311,6 +315,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1796,6 +1801,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1973,6 +1979,7 @@ 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 { @@ -1987,6 +1994,10 @@ 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; @@ -2139,6 +2150,7 @@ 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>, ) { @@ -2160,6 +2172,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2169,6 +2182,7 @@ 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>, ) { @@ -2185,6 +2199,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3375,6 +3390,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3421,6 +3437,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3466,6 +3483,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3730,6 +3748,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3751,6 +3770,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3762,6 +3782,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index bc782e19e6..3526e7c0d0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6599,6 +6599,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7375,6 +7384,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 28015b6a53..c219b089c5 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2627,6 +2627,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 888236ec85f881ccf70543d697488be163ed672b Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 095/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index de45b7ae5f..acf908c5b1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1367,8 +1367,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2230,6 +2236,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2240,6 +2247,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3610,6 +3622,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -3664,6 +3677,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From a47c5b2ab8699037b622fa86c0c60770b1cf8676 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 096/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3c32f190f5..4fcc67b054 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3990,6 +3991,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8316,6 +8322,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3526e7c0d0..58529a1170 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6576,9 +6576,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index c219b089c5..80ac279167 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2807,6 +2807,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3175,6 +3176,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3647,6 +3649,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 47df7199f0..925464d4ce 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6eabdde6ad..d6b2fd6c55 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5d854b73fe..b1bbf02750 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5565,6 +5566,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5780,6 +5789,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7241,8 +7251,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From b68ec54eb3db40ad484fdce8b3e2dc2c2706c33e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 097/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 00b893b76caa1e87ab0cc37e514c122e6e8023ae Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:01:48 -0700 Subject: [PATCH 098/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 58529a1170..0e4e25d261 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9094,6 +9094,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 80ac279167..2ee30fa7c5 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1878,6 +1879,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2329,6 +2332,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2697,6 +2702,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3539,6 +3545,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 40945d497f9675042bbeb999fdce1bb22b79ceb4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 11:55:06 -0700 Subject: [PATCH 099/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 163 +++++++++++++++--- .../app-server-protocol/src/protocol/v2.rs | 9 + .../app-server/src/bespoke_event_handling.rs | 153 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 14 +- codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 7 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 + .../src/codex/rollout_reconstruction_tests.rs | 71 ++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 +++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 20 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 ++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 ++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 ++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 59 files changed, 1175 insertions(+), 61 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d687..2421974130 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -133,14 +133,16 @@ impl ThreadHistoryBuilder { /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - pub fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) -> Option { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message( - payload.message.clone(), - payload.phase.clone(), - payload.memory_citation.clone().map(Into::into), - ), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ); + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -191,18 +193,21 @@ impl ThreadHistoryBuilder { EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), EventMsg::UndoCompleted(_) => {} - EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + EventMsg::TurnAborted(payload) => return self.handle_turn_aborted(payload), EventMsg::TurnStarted(payload) => self.handle_turn_started(payload), - EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload), + EventMsg::TurnComplete(payload) => return self.handle_turn_complete(payload), _ => {} } + None } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { self.current_rollout_index = self.next_rollout_index; self.next_rollout_index += 1; match item { - RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::EventMsg(event) => { + self.handle_event(event); + } RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::ResponseItem(item) => self.handle_response_item(item), RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} @@ -890,24 +895,33 @@ impl ThreadHistoryBuilder { }); } - fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) -> Option { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; - return; + apply_abort(turn); + return Some(Turn::from(&*turn)); } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; - return; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); + return Some(Turn::from(&*turn)); } + None } fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { @@ -915,15 +929,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } - fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) -> Option { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,9 +949,8 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); - return; + mark_completed(current_turn); + return self.finish_current_turn(); } if let Some(turn) = self @@ -942,15 +958,20 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); - return; + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); + mark_completed(current_turn); + return self.finish_current_turn(); } + None } /// Marks the current turn as containing a persisted compaction marker. @@ -976,13 +997,16 @@ impl ThreadHistoryBuilder { self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX); } - fn finish_current_turn(&mut self) { + fn finish_current_turn(&mut self) -> Option { if let Some(turn) = self.current_turn.take() { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { - return; + return None; } - self.turns.push(turn.into()); + let turn = Turn::from(turn); + self.turns.push(turn.clone()); + return Some(turn); } + None } fn new_turn(&mut self, id: Option) -> PendingTurn { @@ -991,6 +1015,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1151,6 +1178,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1201,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1180,6 +1215,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1229,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1342,6 +1383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1404,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1458,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1478,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1491,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1583,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1782,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1801,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1839,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1889,6 +1945,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +2005,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2035,6 +2093,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2106,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2143,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2182,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2195,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2232,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2266,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2330,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2393,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2406,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2424,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2435,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2456,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2469,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2487,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2514,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2525,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2537,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2757,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2770,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2791,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2810,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2829,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2866,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2880,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +2912,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +2928,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..3c32f190f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3691,6 +3691,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 78c60b0aee..dd9104e87d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -128,6 +128,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -255,6 +257,7 @@ fn guardian_auto_approval_review_notification( #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, + terminal_turn: Option, conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, @@ -284,6 +287,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +301,22 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + terminal_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1780,7 +1794,15 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + terminal_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1942,11 +1964,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1954,8 +1983,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2101,6 +2133,8 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, + terminal_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2111,22 +2145,41 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: terminal_turn.and_then(|turn| turn.started_at), + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, + terminal_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: terminal_turn.and_then(|turn| turn.started_at), + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2888,6 +2941,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2901,6 +2957,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); @@ -3280,10 +3354,26 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + let terminal_turn = { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))) + }; handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + terminal_turn, &outgoing, &thread_state, ) @@ -3295,6 +3385,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3328,6 +3421,8 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), + /*terminal_turn*/ None, &outgoing, &thread_state, ) @@ -3339,6 +3434,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3372,6 +3469,8 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + /*terminal_turn*/ None, &outgoing, &thread_state, ) @@ -3390,6 +3489,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3632,7 +3733,15 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3646,11 +3755,27 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..f6198945c7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6593,6 +6593,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6929,6 +6932,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -7347,10 +7353,10 @@ impl CodexMessageProcessor { // Track the event before emitting any typed // translations so thread-local state such as raw event // opt-in stays synchronized with the conversation. - let raw_events_enabled = { + let (raw_events_enabled, terminal_turn) = { let mut thread_state = thread_state.lock().await; - thread_state.track_current_turn_event(&event.msg); - thread_state.experimental_raw_events + let terminal_turn = thread_state.track_current_turn_event(&event.msg); + (thread_state.experimental_raw_events, terminal_turn) }; let subscribed_connection_ids = thread_state_manager .subscribed_connection_ids(conversation_id) @@ -7366,6 +7372,7 @@ impl CodexMessageProcessor { ); apply_bespoke_event_handling( event.clone(), + terminal_turn, conversation_id, conversation.clone(), thread_manager.clone(), @@ -9578,6 +9585,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..441e632213 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index be5478dd51..6981f5dea3 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -108,11 +108,12 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } - pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { - self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) -> Option { + let terminal_turn = self.current_turn_history.handle_event(event); + if terminal_turn.is_some() && !self.current_turn_history.has_active_turn() { self.current_turn_history.reset(); } + terminal_turn } } 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 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b24cc977a2..76a005619e 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -263,6 +263,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -274,6 +275,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -295,6 +298,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1207,6 +1212,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1291,6 +1298,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aeb6cb48b3..28015b6a53 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -242,13 +242,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1307,6 +1313,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1324,6 +1331,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1488,6 +1497,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1506,10 +1516,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1528,6 +1541,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1586,6 +1601,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1602,10 +1618,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1617,10 +1636,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1641,6 +1663,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1668,6 +1692,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1684,10 +1709,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1704,10 +1732,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1724,6 +1755,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4631,6 +4664,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..47df7199f0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + started_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 09b43471df..0484808ad9 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; 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 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631bd..5d854b73fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5887,6 +5887,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5907,6 +5910,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc956..7f42414a86 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -630,6 +646,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From a695f27378dccccfa37376e772cd60e866938297 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 100/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4fd5bd4f95..f5d7443c15 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -202,6 +204,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -219,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( event_turn_id, turn_complete_event, terminal_turn, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1723,6 +1728,7 @@ pub(crate) async fn apply_bespoke_event_handling( event_turn_id, turn_aborted_event, terminal_turn, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1900,6 +1906,7 @@ 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 { @@ -1914,6 +1921,10 @@ 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; @@ -2107,6 +2118,7 @@ async fn handle_turn_complete( event_turn_id: String, turn_complete_event: TurnCompleteEvent, terminal_turn: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2127,6 +2139,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2137,6 +2150,7 @@ async fn handle_turn_interrupted( event_turn_id: String, turn_aborted_event: TurnAbortedEvent, terminal_turn: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2152,6 +2166,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3743,6 +3758,7 @@ mod tests { event_turn_id.clone(), turn_complete_event(&event_turn_id), terminal_turn, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3792,6 +3808,7 @@ mod tests { event_turn_id.clone(), turn_aborted_event(&event_turn_id), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3840,6 +3857,7 @@ mod tests { event_turn_id.clone(), turn_complete_event(&event_turn_id), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4107,6 +4125,7 @@ mod tests { a_turn1.clone(), turn_complete_event(&a_turn1), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4148,7 @@ mod tests { b_turn1.clone(), turn_complete_event(&b_turn1), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4141,6 +4161,7 @@ mod tests { a_turn2.clone(), turn_complete_event(&a_turn2), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f65d205ffc..e583c6253f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7394,6 +7403,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From c2d5e2ab977176c9e4c2e293d0efc4a7bbcd8888 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 101/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f5d7443c15..1496bf4251 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2197,6 +2203,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2207,6 +2214,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3986,6 +3998,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4040,6 +4053,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 98de5a3fe6c76c92aaafcb03ee881c5e608ebead Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 102/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7cefde0f21..6e34bdf1cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3992,6 +3993,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e583c6253f..dcd8e26c59 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6594,9 +6594,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9f042b5eea..c0de6269dd 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -690,6 +690,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89feb139d2..ac5d75f3ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 4289d3b130a5d42779675432947c6c316dc12db2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 103/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From f3bafdcad996e5d765de8bec0fcd573a48f1c167 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:20:58 -0700 Subject: [PATCH 104/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 165 +++++++++++++++--- .../app-server-protocol/src/protocol/v2.rs | 9 + .../app-server/src/bespoke_event_handling.rs | 154 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 14 +- codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 7 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 + .../src/codex/rollout_reconstruction_tests.rs | 71 ++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib.rs | 6 +- codex-rs/exec/src/lib_tests.rs | 6 + .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 20 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 ++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 ++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 ++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 59 files changed, 1178 insertions(+), 61 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..2def84cf6c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -145,14 +145,16 @@ impl ThreadHistoryBuilder { /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - pub fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) -> Option { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message( - payload.message.clone(), - payload.phase.clone(), - payload.memory_citation.clone().map(Into::into), - ), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ); + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -204,18 +206,21 @@ impl ThreadHistoryBuilder { EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), EventMsg::UndoCompleted(_) => {} - EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + EventMsg::TurnAborted(payload) => return self.handle_turn_aborted(payload), EventMsg::TurnStarted(payload) => self.handle_turn_started(payload), - EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload), + EventMsg::TurnComplete(payload) => return self.handle_turn_complete(payload), _ => {} } + None } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { self.current_rollout_index = self.next_rollout_index; self.next_rollout_index += 1; match item { - RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::EventMsg(event) => { + self.handle_event(event); + } RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::ResponseItem(item) => self.handle_response_item(item), RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} @@ -863,24 +868,33 @@ impl ThreadHistoryBuilder { }); } - fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) -> Option { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; - return; + apply_abort(turn); + return Some(Turn::from(&*turn)); } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; - return; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); + return Some(Turn::from(&*turn)); } + None } fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { @@ -888,15 +902,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } - fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) -> Option { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,9 +922,8 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); - return; + mark_completed(current_turn); + return self.finish_current_turn(); } if let Some(turn) = self @@ -915,15 +931,20 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); - return; + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); + mark_completed(current_turn); + return self.finish_current_turn(); } + None } /// Marks the current turn as containing a persisted compaction marker. @@ -949,13 +970,16 @@ impl ThreadHistoryBuilder { self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX); } - fn finish_current_turn(&mut self) { + fn finish_current_turn(&mut self) -> Option { if let Some(turn) = self.current_turn.take() { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { - return; + return None; } - self.turns.push(turn.into()); + let turn = Turn::from(turn); + self.turns.push(turn.clone()); + return Some(turn); } + None } fn new_turn(&mut self, id: Option) -> PendingTurn { @@ -964,6 +988,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1109,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1132,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1146,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1160,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1314,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1335,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1389,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1409,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1422,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1514,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1713,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1732,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1770,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1876,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +1936,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2024,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2097,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2156,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2169,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2206,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2245,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2258,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2295,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2329,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2393,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2456,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2469,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2487,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2498,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2519,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2532,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2550,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2577,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2588,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2600,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2820,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2833,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2854,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2873,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2892,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +2929,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +2943,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +2975,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +2991,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..7cefde0f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 01eb4bd578..4fd5bd4f95 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -161,6 +163,7 @@ struct CommandExecutionCompletionItem { #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, + terminal_turn: Option, conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, @@ -190,6 +193,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +207,22 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + terminal_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1718,15 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + terminal_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1888,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1907,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2105,8 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, + terminal_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2117,41 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: terminal_turn.and_then(|turn| turn.started_at), + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, + terminal_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: terminal_turn.and_then(|turn| turn.started_at), + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2924,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2940,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -2948,6 +3022,7 @@ mod tests { id: event_turn_id, msg: EventMsg::GuardianAssessment(assessment), }, + /*terminal_turn*/ None, self.conversation_id.clone(), self.conversation.clone(), self.thread_manager.clone(), @@ -3648,10 +3723,26 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + let terminal_turn = { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))) + }; handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + terminal_turn, &outgoing, &thread_state, ) @@ -3663,6 +3754,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3790,8 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), + /*terminal_turn*/ None, &outgoing, &thread_state, ) @@ -3707,6 +3803,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3838,8 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + /*terminal_turn*/ None, &outgoing, &thread_state, ) @@ -3758,6 +3858,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4102,15 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4124,27 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..f65d205ffc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6611,6 +6611,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6950,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -7365,10 +7371,10 @@ impl CodexMessageProcessor { // Track the event before emitting any typed // translations so thread-local state such as raw event // opt-in stays synchronized with the conversation. - let raw_events_enabled = { + let (raw_events_enabled, terminal_turn) = { let mut thread_state = thread_state.lock().await; - thread_state.track_current_turn_event(&event.msg); - thread_state.experimental_raw_events + let terminal_turn = thread_state.track_current_turn_event(&event.msg); + (thread_state.experimental_raw_events, terminal_turn) }; let subscribed_connection_ids = thread_state_manager .subscribed_connection_ids(conversation_id) @@ -7384,6 +7390,7 @@ impl CodexMessageProcessor { ); apply_bespoke_event_handling( event.clone(), + terminal_turn, conversation_id, conversation.clone(), thread_manager.clone(), @@ -9596,6 +9603,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..441e632213 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..cb803e0ed7 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -109,11 +109,12 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } - pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { - self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) -> Option { + let terminal_turn = self.current_turn_history.handle_event(event); + if terminal_turn.is_some() && !self.current_turn_history.has_active_turn() { self.current_turn_history.reset(); } + terminal_turn } } 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 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..3a7ceb6f8c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..9f042b5eea 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -719,7 +720,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( TurnStartedNotification { thread_id: response.review_thread_id.clone(), - turn: response.turn.clone(), + turn: Turn { + started_at: Some(0), + ..response.turn.clone() + }, }, )); let task_id = response.turn.id; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..c2bf513c87 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; 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 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From e6908c117102ec128f0a2e8e081fecf468f6c22c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 105/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index dcd8e26c59..f6a75bd6b8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9113,6 +9113,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 6d273dd348837bdaf656a669ff35be112fc8e4b9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:20:58 -0700 Subject: [PATCH 106/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 165 +++++++++++++++--- .../app-server-protocol/src/protocol/v2.rs | 9 + .../app-server/src/bespoke_event_handling.rs | 154 ++++++++++++++-- .../app-server/src/codex_message_processor.rs | 14 +- codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 7 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 + .../src/codex/rollout_reconstruction_tests.rs | 71 ++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib_tests.rs | 9 + .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 20 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 ++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 ++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 ++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 58 files changed, 1176 insertions(+), 60 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..2def84cf6c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -145,14 +145,16 @@ impl ThreadHistoryBuilder { /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - pub fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) -> Option { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message( - payload.message.clone(), - payload.phase.clone(), - payload.memory_citation.clone().map(Into::into), - ), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ); + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -204,18 +206,21 @@ impl ThreadHistoryBuilder { EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), EventMsg::UndoCompleted(_) => {} - EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + EventMsg::TurnAborted(payload) => return self.handle_turn_aborted(payload), EventMsg::TurnStarted(payload) => self.handle_turn_started(payload), - EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload), + EventMsg::TurnComplete(payload) => return self.handle_turn_complete(payload), _ => {} } + None } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { self.current_rollout_index = self.next_rollout_index; self.next_rollout_index += 1; match item { - RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::EventMsg(event) => { + self.handle_event(event); + } RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::ResponseItem(item) => self.handle_response_item(item), RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} @@ -863,24 +868,33 @@ impl ThreadHistoryBuilder { }); } - fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) -> Option { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; - return; + apply_abort(turn); + return Some(Turn::from(&*turn)); } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; - return; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); + return Some(Turn::from(&*turn)); } + None } fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { @@ -888,15 +902,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } - fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) -> Option { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,9 +922,8 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); - return; + mark_completed(current_turn); + return self.finish_current_turn(); } if let Some(turn) = self @@ -915,15 +931,20 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); - return; + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); + mark_completed(current_turn); + return self.finish_current_turn(); } + None } /// Marks the current turn as containing a persisted compaction marker. @@ -949,13 +970,16 @@ impl ThreadHistoryBuilder { self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX); } - fn finish_current_turn(&mut self) { + fn finish_current_turn(&mut self) -> Option { if let Some(turn) = self.current_turn.take() { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { - return; + return None; } - self.turns.push(turn.into()); + let turn = Turn::from(turn); + self.turns.push(turn.clone()); + return Some(turn); } + None } fn new_turn(&mut self, id: Option) -> PendingTurn { @@ -964,6 +988,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1109,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1132,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1146,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1160,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1314,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1335,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1389,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1409,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1422,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1514,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1713,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1732,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1770,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1876,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +1936,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2024,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2097,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2156,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2169,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2206,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2245,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2258,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2295,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2329,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2393,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2456,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2469,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2487,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2498,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2519,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2532,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2550,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2577,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2588,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2600,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2820,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2833,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2854,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2873,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2892,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +2929,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +2943,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +2975,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +2991,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..7cefde0f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 01eb4bd578..4fd5bd4f95 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -161,6 +163,7 @@ struct CommandExecutionCompletionItem { #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, + terminal_turn: Option, conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, @@ -190,6 +193,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +207,22 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + terminal_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1718,15 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + terminal_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1888,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1907,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2105,8 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, + terminal_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2117,41 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: terminal_turn.and_then(|turn| turn.started_at), + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, + terminal_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: terminal_turn.and_then(|turn| turn.started_at), + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2924,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2940,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -2948,6 +3022,7 @@ mod tests { id: event_turn_id, msg: EventMsg::GuardianAssessment(assessment), }, + /*terminal_turn*/ None, self.conversation_id.clone(), self.conversation.clone(), self.thread_manager.clone(), @@ -3648,10 +3723,26 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + let terminal_turn = { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))) + }; handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + terminal_turn, &outgoing, &thread_state, ) @@ -3663,6 +3754,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3790,8 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), + /*terminal_turn*/ None, &outgoing, &thread_state, ) @@ -3707,6 +3803,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3838,8 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + /*terminal_turn*/ None, &outgoing, &thread_state, ) @@ -3758,6 +3858,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4102,15 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4124,27 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + /*terminal_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..f65d205ffc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6611,6 +6611,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6950,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -7365,10 +7371,10 @@ impl CodexMessageProcessor { // Track the event before emitting any typed // translations so thread-local state such as raw event // opt-in stays synchronized with the conversation. - let raw_events_enabled = { + let (raw_events_enabled, terminal_turn) = { let mut thread_state = thread_state.lock().await; - thread_state.track_current_turn_event(&event.msg); - thread_state.experimental_raw_events + let terminal_turn = thread_state.track_current_turn_event(&event.msg); + (thread_state.experimental_raw_events, terminal_turn) }; let subscribed_connection_ids = thread_state_manager .subscribed_connection_ids(conversation_id) @@ -7384,6 +7390,7 @@ impl CodexMessageProcessor { ); apply_bespoke_event_handling( event.clone(), + terminal_turn, conversation_id, conversation.clone(), thread_manager.clone(), @@ -9596,6 +9603,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..441e632213 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..cb803e0ed7 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -109,11 +109,12 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } - pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { - self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) -> Option { + let terminal_turn = self.current_turn_history.handle_event(event); + if terminal_turn.is_some() && !self.current_turn_history.has_active_turn() { self.current_turn_history.reset(); } + terminal_turn } } 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 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..3a7ceb6f8c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..0af5cc5250 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 031eb880e76a23bdfef6ccd67527521fb5eea293 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 107/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 21 + .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1264 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4fd5bd4f95..f5d7443c15 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -202,6 +204,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -219,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( event_turn_id, turn_complete_event, terminal_turn, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1723,6 +1728,7 @@ pub(crate) async fn apply_bespoke_event_handling( event_turn_id, turn_aborted_event, terminal_turn, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1900,6 +1906,7 @@ 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 { @@ -1914,6 +1921,10 @@ 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; @@ -2107,6 +2118,7 @@ async fn handle_turn_complete( event_turn_id: String, turn_complete_event: TurnCompleteEvent, terminal_turn: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2127,6 +2139,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2137,6 +2150,7 @@ async fn handle_turn_interrupted( event_turn_id: String, turn_aborted_event: TurnAbortedEvent, terminal_turn: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2152,6 +2166,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -3743,6 +3758,7 @@ mod tests { event_turn_id.clone(), turn_complete_event(&event_turn_id), terminal_turn, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3792,6 +3808,7 @@ mod tests { event_turn_id.clone(), turn_aborted_event(&event_turn_id), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3840,6 +3857,7 @@ mod tests { event_turn_id.clone(), turn_complete_event(&event_turn_id), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4107,6 +4125,7 @@ mod tests { a_turn1.clone(), turn_complete_event(&a_turn1), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4148,7 @@ mod tests { b_turn1.clone(), turn_complete_event(&b_turn1), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4141,6 +4161,7 @@ mod tests { a_turn2.clone(), turn_complete_event(&a_turn2), /*terminal_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f65d205ffc..e583c6253f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7394,6 +7403,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 472c15a74be2fbacaf0d77aa762b4bbc0c15c092 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 108/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f5d7443c15..1496bf4251 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2197,6 +2203,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2207,6 +2214,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3986,6 +3998,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4040,6 +4053,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 59cfda78e274c19bb2033663e698369e3d243259 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 109/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7cefde0f21..6e34bdf1cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3992,6 +3993,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e583c6253f..dcd8e26c59 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6594,9 +6594,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index d110f4feea..5b0168bbea 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -611,6 +611,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..b103708d34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89feb139d2..ac5d75f3ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 9e7e31966f9fa0a74919a8a282c96572619be8a6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 110/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 70a1fb26bcdf2c2f3b27a83a608f1bf656aee014 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 12:26:33 -0700 Subject: [PATCH 111/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index dcd8e26c59..f6a75bd6b8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9113,6 +9113,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From bab096c2b11ba02bb8790a7d49c93edad1506126 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 13:50:38 -0700 Subject: [PATCH 112/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 165 +++++++++++++++--- .../app-server-protocol/src/protocol/v2.rs | 9 + .../app-server/src/bespoke_event_handling.rs | 156 +++++++++++++++-- .../app-server/src/codex_message_processor.rs | 14 +- codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 7 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 + .../src/codex/rollout_reconstruction_tests.rs | 71 ++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 ++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib_tests.rs | 9 + .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 20 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 ++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 ++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 ++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 58 files changed, 1177 insertions(+), 61 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..2def84cf6c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -145,14 +145,16 @@ impl ThreadHistoryBuilder { /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - pub fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) -> Option { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message( - payload.message.clone(), - payload.phase.clone(), - payload.memory_citation.clone().map(Into::into), - ), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ); + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -204,18 +206,21 @@ impl ThreadHistoryBuilder { EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), EventMsg::UndoCompleted(_) => {} - EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + EventMsg::TurnAborted(payload) => return self.handle_turn_aborted(payload), EventMsg::TurnStarted(payload) => self.handle_turn_started(payload), - EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload), + EventMsg::TurnComplete(payload) => return self.handle_turn_complete(payload), _ => {} } + None } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { self.current_rollout_index = self.next_rollout_index; self.next_rollout_index += 1; match item { - RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::EventMsg(event) => { + self.handle_event(event); + } RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::ResponseItem(item) => self.handle_response_item(item), RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} @@ -863,24 +868,33 @@ impl ThreadHistoryBuilder { }); } - fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) -> Option { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; - return; + apply_abort(turn); + return Some(Turn::from(&*turn)); } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; - return; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); + return Some(Turn::from(&*turn)); } + None } fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { @@ -888,15 +902,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } - fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) -> Option { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,9 +922,8 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); - return; + mark_completed(current_turn); + return self.finish_current_turn(); } if let Some(turn) = self @@ -915,15 +931,20 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); - return; + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); + mark_completed(current_turn); + return self.finish_current_turn(); } + None } /// Marks the current turn as containing a persisted compaction marker. @@ -949,13 +970,16 @@ impl ThreadHistoryBuilder { self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX); } - fn finish_current_turn(&mut self) { + fn finish_current_turn(&mut self) -> Option { if let Some(turn) = self.current_turn.take() { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { - return; + return None; } - self.turns.push(turn.into()); + let turn = Turn::from(turn); + self.turns.push(turn.clone()); + return Some(turn); } + None } fn new_turn(&mut self, id: Option) -> PendingTurn { @@ -964,6 +988,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1109,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1132,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1146,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1160,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1314,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1335,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1389,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1409,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1422,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1514,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1713,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1732,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1770,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1876,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +1936,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2024,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2097,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2156,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2169,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2206,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2245,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2258,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2295,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2329,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2393,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2456,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2469,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2487,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2498,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2519,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2532,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2550,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2577,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2588,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2600,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2820,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2833,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2854,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2873,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2892,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +2929,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +2943,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +2975,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +2991,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..7cefde0f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 01eb4bd578..89d9891475 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -161,6 +163,7 @@ struct CommandExecutionCompletionItem { #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, + completed_turn: Option, conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, @@ -190,6 +193,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +207,22 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + completed_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1718,15 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + completed_turn, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1888,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1907,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2105,8 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, + completed_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2117,41 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: completed_turn.and_then(|turn| turn.started_at), + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, + completed_turn: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let _ = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: completed_turn.and_then(|turn| turn.started_at), + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2924,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2940,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -2948,7 +3022,8 @@ mod tests { id: event_turn_id, msg: EventMsg::GuardianAssessment(assessment), }, - self.conversation_id.clone(), + /*completed_turn*/ None, + self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), self.outgoing.clone(), @@ -3648,10 +3723,26 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + let completed_turn = { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))) + }; handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + completed_turn, &outgoing, &thread_state, ) @@ -3663,6 +3754,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3790,8 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), + /*completed_turn*/ None, &outgoing, &thread_state, ) @@ -3707,6 +3803,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3838,8 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), + /*completed_turn*/ None, &outgoing, &thread_state, ) @@ -3758,6 +3858,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4102,15 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + /*completed_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4124,27 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + /*completed_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + /*completed_turn*/ None, + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..e15d2f01e6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6611,6 +6611,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6950,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -7365,10 +7371,10 @@ impl CodexMessageProcessor { // Track the event before emitting any typed // translations so thread-local state such as raw event // opt-in stays synchronized with the conversation. - let raw_events_enabled = { + let (raw_events_enabled, completed_turn) = { let mut thread_state = thread_state.lock().await; - thread_state.track_current_turn_event(&event.msg); - thread_state.experimental_raw_events + let completed_turn = thread_state.track_current_turn_event(&event.msg); + (thread_state.experimental_raw_events, completed_turn) }; let subscribed_connection_ids = thread_state_manager .subscribed_connection_ids(conversation_id) @@ -7384,6 +7390,7 @@ impl CodexMessageProcessor { ); apply_bespoke_event_handling( event.clone(), + completed_turn, conversation_id, conversation.clone(), thread_manager.clone(), @@ -9596,6 +9603,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index eda2c75da8..441e632213 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -823,6 +823,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..a988528824 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -109,11 +109,12 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } - pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { - self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) -> Option { + let completed_turn = self.current_turn_history.handle_event(event); + if completed_turn.is_some() && !self.current_turn_history.has_active_turn() { self.current_turn_history.reset(); } + completed_turn } } 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 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..3a7ceb6f8c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..0af5cc5250 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 71db64b48ad4dd05621235af99225fa0026c1e62 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 13:52:22 -0700 Subject: [PATCH 113/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1274 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 89d9891475..0d6a5261f1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -202,6 +204,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -219,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( event_turn_id, turn_complete_event, completed_turn, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1723,6 +1728,7 @@ pub(crate) async fn apply_bespoke_event_handling( event_turn_id, turn_aborted_event, completed_turn, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1900,6 +1906,7 @@ 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 { @@ -1914,6 +1921,10 @@ 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; @@ -2107,6 +2118,7 @@ async fn handle_turn_complete( event_turn_id: String, turn_complete_event: TurnCompleteEvent, completed_turn: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2127,6 +2139,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2137,6 +2150,7 @@ async fn handle_turn_interrupted( event_turn_id: String, turn_aborted_event: TurnAbortedEvent, completed_turn: Option, + analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2152,6 +2166,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2890,6 +2905,7 @@ 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; @@ -3011,6 +3027,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3026,6 +3043,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3334,6 +3352,13 @@ 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(), }; @@ -3743,6 +3768,7 @@ mod tests { event_turn_id.clone(), turn_complete_event(&event_turn_id), completed_turn, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3792,6 +3818,7 @@ mod tests { event_turn_id.clone(), turn_aborted_event(&event_turn_id), /*completed_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3840,6 +3867,7 @@ mod tests { event_turn_id.clone(), turn_complete_event(&event_turn_id), /*completed_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4107,6 +4135,7 @@ mod tests { a_turn1.clone(), turn_complete_event(&a_turn1), /*completed_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4158,7 @@ mod tests { b_turn1.clone(), turn_complete_event(&b_turn1), /*completed_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4141,6 +4171,7 @@ mod tests { a_turn2.clone(), turn_complete_event(&a_turn2), /*completed_turn*/ None, + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e15d2f01e6..43ccc6a9c2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7394,6 +7403,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 02b948bc1f..78cb5b994f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -224,7 +224,12 @@ impl MessageProcessor { outgoing: outgoing.clone(), }), ); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -234,13 +239,9 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -677,6 +678,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 81394bede07facb86ccf02f04ec12c508ce0d1f9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:19:32 -0700 Subject: [PATCH 114/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 ++ .../codex_app_server_protocol.schemas.json | 24 ++ .../codex_app_server_protocol.v2.schemas.json | 24 ++ .../schema/json/v2/ReviewStartResponse.json | 24 ++ .../schema/json/v2/ThreadForkResponse.json | 24 ++ .../schema/json/v2/ThreadListResponse.json | 24 ++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 ++ .../schema/json/v2/ThreadReadResponse.json | 24 ++ .../schema/json/v2/ThreadResumeResponse.json | 24 ++ .../json/v2/ThreadRollbackResponse.json | 24 ++ .../schema/json/v2/ThreadStartResponse.json | 24 ++ .../json/v2/ThreadStartedNotification.json | 24 ++ .../json/v2/ThreadUnarchiveResponse.json | 24 ++ .../json/v2/TurnCompletedNotification.json | 24 ++ .../schema/json/v2/TurnStartResponse.json | 24 ++ .../json/v2/TurnStartedNotification.json | 24 ++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 256 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 9 + .../app-server/src/bespoke_event_handling.rs | 142 +++++++++- .../app-server/src/codex_message_processor.rs | 7 + codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 10 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 + .../src/codex/rollout_reconstruction_tests.rs | 71 +++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 +++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 + codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 + .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 ++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 + ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib_tests.rs | 9 + .../tests/event_processor_with_json_output.rs | 33 +++ codex-rs/protocol/src/protocol.rs | 24 +- codex-rs/tui/src/app.rs | 20 +- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 ++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 ++ .../src/chatwidget/tests/history_replay.rs | 15 + .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 + .../tui/src/chatwidget/tests/review_mode.rs | 21 ++ .../src/chatwidget/tests/slash_commands.rs | 17 ++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 58 files changed, 1255 insertions(+), 56 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..9bc6570aae 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -145,14 +145,16 @@ impl ThreadHistoryBuilder { /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - pub fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) -> Option { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message( - payload.message.clone(), - payload.phase.clone(), - payload.memory_citation.clone().map(Into::into), - ), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ); + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -204,18 +206,32 @@ impl ThreadHistoryBuilder { EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), EventMsg::UndoCompleted(_) => {} - EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + EventMsg::TurnAborted(payload) => return self.handle_turn_aborted(payload), EventMsg::TurnStarted(payload) => self.handle_turn_started(payload), - EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload), + EventMsg::TurnComplete(payload) => return self.handle_turn_complete(payload), _ => {} } + None + } + + pub fn handle_event_without_completed_turn(&mut self, event: &EventMsg) -> bool { + match event { + EventMsg::TurnAborted(payload) => self.handle_turn_aborted_without_snapshot(payload), + EventMsg::TurnComplete(payload) => self.handle_turn_complete_without_snapshot(payload), + _ => { + self.handle_event(event); + false + } + } } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { self.current_rollout_index = self.next_rollout_index; self.next_rollout_index += 1; match item { - RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::EventMsg(event) => { + self.handle_event(event); + } RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::ResponseItem(item) => self.handle_response_item(item), RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} @@ -863,24 +879,62 @@ impl ThreadHistoryBuilder { }); } - fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) -> Option { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; - return; + apply_abort(turn); + return Some(Turn::from(&*turn)); } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; - return; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); + return Some(Turn::from(&*turn)); } + None + } + + fn handle_turn_aborted_without_snapshot(&mut self, payload: &TurnAbortedEvent) -> bool { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; + if let Some(turn_id) = payload.turn_id.as_deref() { + // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. + if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { + apply_abort(turn); + return true; + } + + if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return true; + } + } + + // If the event has no ID (or refers to an unknown turn), fall back to the active turn. + if let Some(turn) = self.current_turn.as_mut() { + apply_abort(turn); + return true; + } + false } fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { @@ -888,15 +942,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } - fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) -> Option { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,9 +962,8 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); - return; + mark_completed(current_turn); + return self.finish_current_turn(); } if let Some(turn) = self @@ -915,15 +971,60 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); - return; + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); + mark_completed(current_turn); + return self.finish_current_turn(); } + None + } + + fn handle_turn_complete_without_snapshot(&mut self, payload: &TurnCompleteEvent) -> bool { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; + + // Prefer an exact ID match from the active turn and then close it. + if let Some(current_turn) = self + .current_turn + .as_mut() + .filter(|turn| turn.id == payload.turn_id) + { + mark_completed(current_turn); + return self.finish_current_turn_without_snapshot(); + } + + if let Some(turn) = self + .turns + .iter_mut() + .find(|turn| turn.id == payload.turn_id) + { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return true; + } + + // If the completion event cannot be matched, apply it to the active turn. + if let Some(current_turn) = self.current_turn.as_mut() { + mark_completed(current_turn); + return self.finish_current_turn_without_snapshot(); + } + false } /// Marks the current turn as containing a persisted compaction marker. @@ -949,13 +1050,27 @@ impl ThreadHistoryBuilder { self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX); } - fn finish_current_turn(&mut self) { + fn finish_current_turn(&mut self) -> Option { if let Some(turn) = self.current_turn.take() { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { - return; + return None; } - self.turns.push(turn.into()); + let turn = Turn::from(turn); + self.turns.push(turn.clone()); + return Some(turn); } + None + } + + fn finish_current_turn_without_snapshot(&mut self) -> bool { + if let Some(turn) = self.current_turn.take() { + if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { + return false; + } + self.turns.push(Turn::from(turn)); + return true; + } + false } fn new_turn(&mut self, id: Option) -> PendingTurn { @@ -964,6 +1079,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1200,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1223,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1237,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1251,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1405,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1426,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1480,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1500,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1513,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1605,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1804,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1823,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1861,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1967,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +2027,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2115,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2188,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2247,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2260,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2297,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2336,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2349,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2386,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2420,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2484,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2547,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2560,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2578,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2589,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2610,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2623,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2641,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2668,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2679,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2691,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2911,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2924,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2945,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2964,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2983,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +3020,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +3034,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +3066,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +3082,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..7cefde0f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f883d078e4..141ee78bdb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -190,6 +192,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +206,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1716,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1885,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1904,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2102,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2113,40 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2919,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2935,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -3648,10 +3717,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3663,6 +3747,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3783,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3707,6 +3795,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3830,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3758,6 +3849,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4093,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4114,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..6efaf5680d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6611,6 +6611,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6950,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9596,6 +9602,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 71beb58dc0..5d8cf052c5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -826,6 +826,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..49e0c73dbb 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -45,6 +45,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, @@ -110,8 +111,13 @@ impl ThreadState { } pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { - self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } + let completed_turn = self + .current_turn_history + .handle_event_without_completed_turn(event); + if completed_turn && !self.current_turn_history.has_active_turn() { self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..3a7ceb6f8c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..0af5cc5250 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 23f327c3048584b4c88873d072acc842b50fa5ca Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:19:32 -0700 Subject: [PATCH 115/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1274 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6efaf5680d..6973ffd0ae 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7393,6 +7402,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8e3c5be81c..b7af685d16 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 0d848f34d4841bbc39814ecf83de622cd5979f75 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:23:25 -0700 Subject: [PATCH 116/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 3feeaf3b3b..fd7cf4249c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1338,8 +1338,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2192,6 +2198,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2202,6 +2209,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3987,6 +3999,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4041,6 +4054,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 3364e5711d7ff6d63bd6b683dac187ef61635954 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:23:25 -0700 Subject: [PATCH 117/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7cefde0f21..6e34bdf1cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3992,6 +3993,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6973ffd0ae..fd8a1a7540 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6594,9 +6594,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..b103708d34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89feb139d2..ac5d75f3ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From a548a383cb085574d909e71862f59fcebf504f75 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:23:25 -0700 Subject: [PATCH 118/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From c10e642b9de7937a4011da5933e7fea2ba4d3c27 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:23:25 -0700 Subject: [PATCH 119/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fd8a1a7540..03be5c95df 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9112,6 +9112,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 5aec0955594b8ed84f2c203d6d1bdc4e7088375f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 120/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 ++ .../codex_app_server_protocol.schemas.json | 24 ++ .../codex_app_server_protocol.v2.schemas.json | 24 ++ .../schema/json/v2/ReviewStartResponse.json | 24 ++ .../schema/json/v2/ThreadForkResponse.json | 24 ++ .../schema/json/v2/ThreadListResponse.json | 24 ++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 ++ .../schema/json/v2/ThreadReadResponse.json | 24 ++ .../schema/json/v2/ThreadResumeResponse.json | 24 ++ .../json/v2/ThreadRollbackResponse.json | 24 ++ .../schema/json/v2/ThreadStartResponse.json | 24 ++ .../json/v2/ThreadStartedNotification.json | 24 ++ .../json/v2/ThreadUnarchiveResponse.json | 24 ++ .../json/v2/TurnCompletedNotification.json | 24 ++ .../schema/json/v2/TurnStartResponse.json | 24 ++ .../json/v2/TurnStartedNotification.json | 24 ++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 256 ++++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 9 + .../app-server/src/bespoke_event_handling.rs | 142 +++++++++- .../app-server/src/codex_message_processor.rs | 7 + codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 10 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 + .../src/codex/rollout_reconstruction_tests.rs | 71 +++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 +++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 + codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 + .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 ++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 + ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib_tests.rs | 9 + .../tests/event_processor_with_json_output.rs | 33 +++ codex-rs/protocol/src/protocol.rs | 24 +- codex-rs/tui/src/app.rs | 20 +- codex-rs/tui/src/app/app_server_adapter.rs | 42 ++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 ++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 ++ .../src/chatwidget/tests/history_replay.rs | 15 + .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 + .../tui/src/chatwidget/tests/review_mode.rs | 21 ++ .../src/chatwidget/tests/slash_commands.rs | 17 ++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 58 files changed, 1255 insertions(+), 56 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..9bc6570aae 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -145,14 +145,16 @@ impl ThreadHistoryBuilder { /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - pub fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) -> Option { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message( - payload.message.clone(), - payload.phase.clone(), - payload.memory_citation.clone().map(Into::into), - ), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ); + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -204,18 +206,32 @@ impl ThreadHistoryBuilder { EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), EventMsg::UndoCompleted(_) => {} - EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload), + EventMsg::TurnAborted(payload) => return self.handle_turn_aborted(payload), EventMsg::TurnStarted(payload) => self.handle_turn_started(payload), - EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload), + EventMsg::TurnComplete(payload) => return self.handle_turn_complete(payload), _ => {} } + None + } + + pub fn handle_event_without_completed_turn(&mut self, event: &EventMsg) -> bool { + match event { + EventMsg::TurnAborted(payload) => self.handle_turn_aborted_without_snapshot(payload), + EventMsg::TurnComplete(payload) => self.handle_turn_complete_without_snapshot(payload), + _ => { + self.handle_event(event); + false + } + } } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { self.current_rollout_index = self.next_rollout_index; self.next_rollout_index += 1; match item { - RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::EventMsg(event) => { + self.handle_event(event); + } RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::ResponseItem(item) => self.handle_response_item(item), RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} @@ -863,24 +879,62 @@ impl ThreadHistoryBuilder { }); } - fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) -> Option { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; - return; + apply_abort(turn); + return Some(Turn::from(&*turn)); } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; - return; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); + return Some(Turn::from(&*turn)); } + None + } + + fn handle_turn_aborted_without_snapshot(&mut self, payload: &TurnAbortedEvent) -> bool { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; + if let Some(turn_id) = payload.turn_id.as_deref() { + // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. + if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { + apply_abort(turn); + return true; + } + + if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return true; + } + } + + // If the event has no ID (or refers to an unknown turn), fall back to the active turn. + if let Some(turn) = self.current_turn.as_mut() { + apply_abort(turn); + return true; + } + false } fn handle_turn_started(&mut self, payload: &TurnStartedEvent) { @@ -888,15 +942,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } - fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) -> Option { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,9 +962,8 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); - return; + mark_completed(current_turn); + return self.finish_current_turn(); } if let Some(turn) = self @@ -915,15 +971,60 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); - return; + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return Some(turn.clone()); } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); - self.finish_current_turn(); + mark_completed(current_turn); + return self.finish_current_turn(); } + None + } + + fn handle_turn_complete_without_snapshot(&mut self, payload: &TurnCompleteEvent) -> bool { + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; + + // Prefer an exact ID match from the active turn and then close it. + if let Some(current_turn) = self + .current_turn + .as_mut() + .filter(|turn| turn.id == payload.turn_id) + { + mark_completed(current_turn); + return self.finish_current_turn_without_snapshot(); + } + + if let Some(turn) = self + .turns + .iter_mut() + .find(|turn| turn.id == payload.turn_id) + { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + return true; + } + + // If the completion event cannot be matched, apply it to the active turn. + if let Some(current_turn) = self.current_turn.as_mut() { + mark_completed(current_turn); + return self.finish_current_turn_without_snapshot(); + } + false } /// Marks the current turn as containing a persisted compaction marker. @@ -949,13 +1050,27 @@ impl ThreadHistoryBuilder { self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX); } - fn finish_current_turn(&mut self) { + fn finish_current_turn(&mut self) -> Option { if let Some(turn) = self.current_turn.take() { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { - return; + return None; } - self.turns.push(turn.into()); + let turn = Turn::from(turn); + self.turns.push(turn.clone()); + return Some(turn); } + None + } + + fn finish_current_turn_without_snapshot(&mut self) -> bool { + if let Some(turn) = self.current_turn.take() { + if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { + return false; + } + self.turns.push(Turn::from(turn)); + return true; + } + false } fn new_turn(&mut self, id: Option) -> PendingTurn { @@ -964,6 +1079,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1200,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1223,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1237,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1251,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1405,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1426,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1480,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1500,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1513,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1605,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1804,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1823,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1861,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1967,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +2027,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2115,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2188,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2247,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2260,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2297,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2336,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2349,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2386,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2420,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2484,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2547,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2560,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2578,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2589,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2610,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2623,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2641,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2668,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2679,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2691,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2911,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2924,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2945,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2964,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2983,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +3020,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +3034,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +3066,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +3082,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..7cefde0f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f883d078e4..141ee78bdb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -190,6 +192,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +206,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1716,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1885,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1904,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2102,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2113,40 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2919,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2935,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -3648,10 +3717,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3663,6 +3747,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3783,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3707,6 +3795,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3830,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3758,6 +3849,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4093,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4114,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..6efaf5680d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6611,6 +6611,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6950,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9596,6 +9602,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 71beb58dc0..5d8cf052c5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -826,6 +826,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..6dc5d33faa 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -45,6 +45,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, @@ -110,8 +111,13 @@ impl ThreadState { } pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { - self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } + let turn_completed = self + .current_turn_history + .handle_event_without_completed_turn(event); + if turn_completed && !self.current_turn_history.has_active_turn() { self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..3a7ceb6f8c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..0af5cc5250 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From 46bfa522288e9bf778403f6f273ad8760ed12f20 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 121/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1274 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6efaf5680d..6973ffd0ae 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7393,6 +7402,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8e3c5be81c..b7af685d16 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 849f3790284f0bcca2455684666c4c260495fd34 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 122/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 3feeaf3b3b..fd7cf4249c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1338,8 +1338,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2192,6 +2198,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2202,6 +2209,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3987,6 +3999,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4041,6 +4054,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 2d41b51551a215194c111c3d06531192f80aa489 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 123/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7cefde0f21..6e34bdf1cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3992,6 +3993,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6973ffd0ae..fd8a1a7540 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6594,9 +6594,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..b103708d34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89feb139d2..ac5d75f3ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 00b840ec2a11b0082009798e8edbc8e5a59e59f7 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 124/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From bb12a847d1709f1f9002658d51ce302d31b161d5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 125/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fd8a1a7540..03be5c95df 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9112,6 +9112,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 964a9a5aede952422926d67aa33abf1903bb1caa Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 126/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 133 ++++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 9 ++ .../app-server/src/bespoke_event_handling.rs | 142 ++++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 + codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 8 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 +++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib_tests.rs | 9 ++ .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 20 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 +++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 58 files changed, 1144 insertions(+), 42 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..91f3bb8fe1 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -148,11 +148,13 @@ impl ThreadHistoryBuilder { pub fn handle_event(&mut self, event: &EventMsg) { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message( - payload.message.clone(), - payload.phase.clone(), - payload.memory_citation.clone().map(Into::into), - ), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message( + payload.message.clone(), + payload.phase.clone(), + payload.memory_citation.clone().map(Into::into), + ); + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -215,7 +217,9 @@ impl ThreadHistoryBuilder { self.current_rollout_index = self.next_rollout_index; self.next_rollout_index += 1; match item { - RolloutItem::EventMsg(event) => self.handle_event(event), + RolloutItem::EventMsg(event) => { + self.handle_event(event); + } RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::ResponseItem(item) => self.handle_response_item(item), RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {} @@ -864,22 +868,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -888,15 +899,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,7 +919,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -915,13 +929,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -954,7 +972,7 @@ impl ThreadHistoryBuilder { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { return; } - self.turns.push(turn.into()); + self.turns.push(Turn::from(turn)); } } @@ -964,6 +982,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1103,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1126,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1140,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1154,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1308,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1329,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1383,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1403,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1416,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1508,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1707,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1726,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1764,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1870,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +1930,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2018,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2091,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2150,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2163,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2200,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2239,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2252,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2289,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2323,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2387,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2450,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2463,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2481,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2492,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2513,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2526,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2544,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2571,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2582,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2594,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2814,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2827,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2848,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2867,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2886,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +2923,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +2937,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +2969,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +2985,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..7cefde0f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f883d078e4..141ee78bdb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -190,6 +192,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +206,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1716,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1885,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1904,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2102,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2113,40 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2919,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2935,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -3648,10 +3717,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3663,6 +3747,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3783,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3707,6 +3795,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3830,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3758,6 +3849,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4093,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4114,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..6efaf5680d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6611,6 +6611,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6950,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9596,6 +9602,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 71beb58dc0..5d8cf052c5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -826,6 +826,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..0fe835fc75 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -45,6 +45,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, @@ -110,8 +111,13 @@ impl ThreadState { } pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) + && !self.current_turn_history.has_active_turn() + { self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..3a7ceb6f8c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..0af5cc5250 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From f3ff02108dbd3e182535c05f97c13d83b21d2e4d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 127/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1274 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6efaf5680d..6973ffd0ae 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7393,6 +7402,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8e3c5be81c..b7af685d16 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From a33fa56f462b81ac2ab89d04e4ad1301342bfa6f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 128/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 3feeaf3b3b..fd7cf4249c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1338,8 +1338,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2192,6 +2198,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2202,6 +2209,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3987,6 +3999,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4041,6 +4054,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 0467cc1f41e8853b687e15b7fe082c91dcadc458 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 129/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7cefde0f21..6e34bdf1cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3992,6 +3993,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6973ffd0ae..fd8a1a7540 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6594,9 +6594,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..b103708d34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89feb139d2..ac5d75f3ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 504b5336f6144e547675a30c02e2911222b4b909 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 130/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From b78c41e1766a7310467d23cfb56659d68439d99f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 131/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fd8a1a7540..03be5c95df 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9112,6 +9112,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 9c63a0c333dab8a63d7a642c297473b2497b3e4d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 132/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1274 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..0fced56907 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "created_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6efaf5680d..6973ffd0ae 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7393,6 +7402,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8e3c5be81c..b7af685d16 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From fc5ffc3d22198d16209c500f2c090bbebc2ec535 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 133/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0fced56907..5a19a39b85 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "created_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 3feeaf3b3b..fd7cf4249c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1338,8 +1338,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2192,6 +2198,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2202,6 +2209,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3987,6 +3999,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4041,6 +4054,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 35a16a4979851303ab7e03244e48b04afb836490 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 134/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5a19a39b85..bfae62d21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..a001a69997 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2473,6 +2473,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3241,6 +3248,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 63d1ec5902..582c7dcb30 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 @@ -12065,6 +12065,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14626,6 +14633,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 96bdfd41ee..6dc97b4d9d 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 @@ -9920,6 +9920,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12481,6 +12488,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7cefde0f21..6e34bdf1cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3992,6 +3993,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8319,6 +8325,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6973ffd0ae..fd8a1a7540 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6594,9 +6594,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..b103708d34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89feb139d2..ac5d75f3ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2231,6 +2231,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2313,6 +2314,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 4639cbb2517b975556f135f433c47742dc8f6bf6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 135/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bfae62d21f..2ed8316cf3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From b5474cf38c77bd50dc15f4dd0e6c817745aed45c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 136/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2ed8316cf3..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "created_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fd8a1a7540..03be5c95df 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9112,6 +9112,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From f01d37036e5a3c8998ae12dfe9343ad815cb2928 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 14:46:08 -0700 Subject: [PATCH 137/383] [codex-analytics] add protocol-native turn timestamps --- codex-rs/app-server-client/src/lib.rs | 6 + .../schema/json/ServerNotification.json | 24 +++ .../codex_app_server_protocol.schemas.json | 24 +++ .../codex_app_server_protocol.v2.schemas.json | 24 +++ .../schema/json/v2/ReviewStartResponse.json | 24 +++ .../schema/json/v2/ThreadForkResponse.json | 24 +++ .../schema/json/v2/ThreadListResponse.json | 24 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 24 +++ .../schema/json/v2/ThreadReadResponse.json | 24 +++ .../schema/json/v2/ThreadResumeResponse.json | 24 +++ .../json/v2/ThreadRollbackResponse.json | 24 +++ .../schema/json/v2/ThreadStartResponse.json | 24 +++ .../json/v2/ThreadStartedNotification.json | 24 +++ .../json/v2/ThreadUnarchiveResponse.json | 24 +++ .../json/v2/TurnCompletedNotification.json | 24 +++ .../schema/json/v2/TurnStartResponse.json | 24 +++ .../json/v2/TurnStartedNotification.json | 24 +++ .../schema/typescript/v2/Turn.ts | 14 +- .../src/protocol/thread_history.rs | 117 +++++++++++++-- .../app-server-protocol/src/protocol/v2.rs | 9 ++ .../app-server/src/bespoke_event_handling.rs | 142 ++++++++++++++++-- .../app-server/src/codex_message_processor.rs | 7 + codex-rs/app-server/src/in_process.rs | 3 + codex-rs/app-server/src/thread_state.rs | 8 +- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 9 ++ .../src/codex/rollout_reconstruction_tests.rs | 71 +++++++++ codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/codex_tests.rs | 48 +++++- codex-rs/core/src/compact.rs | 1 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/tasks/mod.rs | 13 ++ codex-rs/core/src/tasks/regular.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/thread_manager.rs | 2 + codex-rs/core/src/thread_manager_tests.rs | 12 ++ .../src/tools/handlers/multi_agents_tests.rs | 8 + codex-rs/core/src/turn_timing.rs | 24 +++ codex-rs/core/tests/suite/resume_warning.rs | 3 + ...event_processor_with_human_output_tests.rs | 15 ++ ...event_processor_with_jsonl_output_tests.rs | 3 + codex-rs/exec/src/lib_tests.rs | 9 ++ .../tests/event_processor_with_json_output.rs | 33 ++++ codex-rs/protocol/src/protocol.rs | 24 ++- codex-rs/tui/src/app.rs | 20 ++- codex-rs/tui/src/app/app_server_adapter.rs | 42 +++++- .../tui/src/app/pending_interactive_replay.rs | 3 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 6 + .../tui/src/chatwidget/tests/app_server.rs | 18 +++ .../chatwidget/tests/composer_submission.rs | 6 + .../tui/src/chatwidget/tests/exec_flow.rs | 17 +++ .../src/chatwidget/tests/history_replay.rs | 15 ++ .../tui/src/chatwidget/tests/mcp_startup.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 14 ++ .../tui/src/chatwidget/tests/review_mode.rs | 21 +++ .../src/chatwidget/tests/slash_commands.rs | 17 +++ .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 58 files changed, 1134 insertions(+), 36 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..f7f24eacdf 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1060,6 +1060,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1834,6 +1837,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b8b539a03b..116844171b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3532,6 +3532,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3553,6 +3569,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a589903032..63d1ec5902 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14329,6 +14329,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14350,6 +14366,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f041f8aae8..96bdfd41ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12184,6 +12184,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -12205,6 +12221,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..4bdeebf573 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 88448a1658..a79ac103c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f26bd03a34..e8211b2521 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 88c8e688df..08c4696bb3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8453207380..f6d67d5c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e21f253b72..1697435049 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d719ba7d8f..e664490df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 27a8cdd6bf..98e022c751 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1856,6 +1856,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1877,6 +1893,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c202363e3b..f640c40969 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 542aea1765..1c10ea3b84 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1614,6 +1614,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1635,6 +1651,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..fc1deb675f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..5b79f4df11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..ccbe93e9c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1267,6 +1267,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1304,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 99a8f6e626..10d55bb231 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -864,22 +864,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -888,15 +895,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -905,7 +915,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -915,13 +925,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -954,7 +968,7 @@ impl ThreadHistoryBuilder { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { return; } - self.turns.push(turn.into()); + self.turns.push(Turn::from(turn)); } } @@ -964,6 +978,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1082,6 +1099,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1102,6 +1122,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1111,6 +1136,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1122,6 +1150,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1273,6 +1304,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1293,6 +1325,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1345,6 +1379,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1364,6 +1399,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1375,6 +1412,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1464,6 +1504,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1661,6 +1703,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1679,6 +1722,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1715,6 +1760,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1820,6 +1866,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1879,6 +1926,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1966,6 +2014,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2038,6 +2087,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2096,6 +2146,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2108,9 +2159,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2142,6 +2196,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2179,6 +2235,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2191,9 +2248,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2225,6 +2285,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2257,6 +2319,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2320,6 +2383,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2382,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2394,9 +2459,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2409,6 +2477,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2418,6 +2488,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2437,6 +2509,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2449,9 +2522,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2464,6 +2540,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2489,6 +2567,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2499,6 +2578,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2509,6 +2590,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2726,6 +2810,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2738,6 +2823,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2757,6 +2844,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2773,6 +2863,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2791,6 +2882,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2826,6 +2919,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2839,6 +2933,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2869,6 +2965,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2884,6 +2981,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index cdc78647a1..7cefde0f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3693,6 +3693,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f883d078e4..141ee78bdb 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -127,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -190,6 +192,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -201,14 +206,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -1704,7 +1716,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1866,11 +1885,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1878,8 +1904,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -2073,6 +2102,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2083,22 +2113,40 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2871,6 +2919,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2884,6 +2935,24 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { CommandExecutionCompletionItem { command: command.to_string(), @@ -3648,10 +3717,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3663,6 +3747,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3696,6 +3783,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3707,6 +3795,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3740,6 +3830,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3758,6 +3849,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -4000,7 +4093,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -4014,11 +4114,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 86c2218bc8..6efaf5680d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6611,6 +6611,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6947,6 +6950,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -9596,6 +9602,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 71beb58dc0..5d8cf052c5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -826,6 +826,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 17823fefd9..0fe835fc75 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -45,6 +45,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, @@ -110,8 +111,13 @@ impl ThreadState { } pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) + && !self.current_turn_history.has_active_turn() + { self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 83ed147e12..2b1d6a8ddf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -492,6 +492,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d254a49343..a64a11bf98 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -256,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -267,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -288,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -1200,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1284,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 5dd4f60e14..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index a10b0c33e0..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 85404d4d56..3a7ceb6f8c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -235,13 +235,19 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { @@ -1300,6 +1306,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1317,6 +1324,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1481,6 +1490,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1499,10 +1509,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1521,6 +1534,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1579,6 +1594,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1595,10 +1611,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1610,10 +1629,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1634,6 +1656,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1661,6 +1685,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1677,10 +1702,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1697,10 +1725,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1717,6 +1748,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -4624,6 +4657,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 27cf069268..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 118d460b8e..5bc7944d28 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5272921586..f33e6886f4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -511,9 +511,15 @@ impl Session { &[("token_type", "reasoning_output"), tmp_mem], ); } + let (completed_at, duration_ms) = turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + completed_at, + duration_ms, }); self.send_event(turn_context.as_ref(), event).await; @@ -588,9 +594,16 @@ impl Session { self.flush_rollout().await; } + let (completed_at, duration_ms) = task + .turn_context + .turn_timing_state + .completed_at_and_duration_ms() + .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + completed_at, + duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index f2a29ee7ab..2a26dbccad 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -46,6 +46,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index bd473138b6..3181e73698 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -111,6 +111,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f560a306b8..6d93a1a329 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1010,6 +1010,8 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); match history { diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 92ecd68afa..3db1735822 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -164,6 +164,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -320,6 +321,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted fork history"), @@ -334,6 +337,8 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]) .expect("serialize expected interrupted empty history"), @@ -349,6 +354,8 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })), ]); @@ -485,6 +492,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor EventMsg::TurnAborted(TurnAbortedEvent { turn_id: expected_turn_id, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), )) .expect("serialize interrupted abort event"); @@ -536,6 +545,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -594,6 +604,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })) if turn_id == "turn-explicit" ) })); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f31..eab43f899c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -837,6 +837,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1337,6 +1339,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1363,6 +1367,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1518,6 +1524,8 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(aborted_turn.sub_id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..a4451a52f9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,7 @@ use std::time::Duration; use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -45,6 +47,7 @@ pub(crate) struct TurnTimingState { #[derive(Debug, Default)] struct TurnTimingStateInner { started_at: Option, + started_at_unix_secs: Option, first_token_at: Option, first_message_at: Option, } @@ -53,10 +56,24 @@ impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) { let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.started_at_unix_secs = Some(now_unix_timestamp_secs()); state.first_token_at = None; state.first_message_at = None; } + pub(crate) async fn started_at_unix_secs(&self) -> Option { + self.state.lock().await.started_at_unix_secs + } + + pub(crate) async fn completed_at_and_duration_ms(&self) -> (Option, Option) { + let state = self.state.lock().await; + let completed_at = Some(now_unix_timestamp_secs()); + let duration_ms = state + .started_at + .map(|started_at| i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)); + (completed_at, duration_ms) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -77,6 +94,13 @@ impl TurnTimingState { } } +fn now_unix_timestamp_secs() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + impl TurnTimingStateInner { fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0252a99271..f9ad64c771 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -53,6 +53,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })), @@ -66,6 +67,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 2b625dd564..232be7f02c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -167,6 +167,9 @@ fn turn_completed_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -211,6 +214,9 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -251,6 +257,9 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -291,6 +300,9 @@ fn turn_failed_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); @@ -332,6 +344,9 @@ fn turn_interrupted_clears_stale_final_message() { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index ffb4d1ed01..2a26ec3c7e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -38,6 +38,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }, )); diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 4746ed20d0..0af5cc5250 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -268,6 +268,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), @@ -277,6 +280,9 @@ fn turn_items_for_thread_returns_matching_turn_items() { }], status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], }; @@ -303,6 +309,9 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }); diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 5491e895e2..c22207c914 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -144,6 +144,9 @@ fn turn_started_emits_turn_started_event() { items: Vec::new(), status: TurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, })); @@ -1066,6 +1069,9 @@ fn plan_update_emits_started_then_updated_then_completed() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1122,6 +1128,9 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1201,6 +1210,9 @@ fn token_usage_update_is_emitted_on_turn_completion() { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1236,6 +1248,9 @@ fn turn_completion_recovers_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1310,6 +1325,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1367,6 +1385,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1407,6 +1428,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1455,6 +1479,9 @@ fn failed_turn_clears_stale_final_message() { additional_details: None, codex_error_info: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1478,6 +1505,9 @@ fn turn_completion_falls_back_to_final_plan_text() { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); @@ -1526,6 +1556,9 @@ fn turn_failure_prefers_structured_error_message() { items: Vec::new(), status: TurnStatus::Failed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5e0c6010e4..7116878b47 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1864,11 +1864,23 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + /// Unix timestamp (in seconds) when the turn started. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub started_at: Option, // TODO(aibrahim): make this not optional pub model_context_window: Option, #[serde(default)] @@ -3375,6 +3387,14 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Unix timestamp (in seconds) when the turn was aborted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub completed_at: Option, + /// Duration between turn start and abort in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub duration_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -4543,7 +4563,9 @@ mod tests { }))?; match event { - EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) => { + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) => { assert_eq!(turn_id, None); assert_eq!(reason, TurnAbortReason::Interrupted); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac93..89feb139d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9186,13 +9186,19 @@ guardian_approval = true items, status, error: None, + started_at: None, + completed_at: None, + duration_ms: None, } } fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification { ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, TurnStatus::InProgress, Vec::new()), + turn: Turn { + started_at: Some(0), + ..test_turn(turn_id, TurnStatus::InProgress, Vec::new()) + }, }) } @@ -9203,7 +9209,11 @@ guardian_approval = true ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: thread_id.to_string(), - turn: test_turn(turn_id, status, Vec::new()), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn_id, status, Vec::new()) + }, }) } @@ -10424,6 +10434,9 @@ guardian_approval = true }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-2".to_string(), @@ -10444,6 +10457,9 @@ guardian_approval = true ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, ], events: Vec::new(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e32..995af44b28 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -501,6 +501,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: notification.turn.id, + started_at: notification.turn.started_at, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -676,6 +677,7 @@ fn turn_snapshot_events( id: String::new(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn.id.clone(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::default(), }), @@ -741,6 +743,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Interrupted => events.push(Event { @@ -748,6 +752,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn.id.clone()), reason: TurnAbortReason::Interrupted, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }), TurnStatus::Failed => { @@ -768,6 +774,8 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.id.clone(), last_agent_message: None, + completed_at: turn.completed_at, + duration_ms: turn.duration_ms, }), }); } @@ -1103,6 +1111,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1121,6 +1132,8 @@ mod tests { }; assert_eq!(completed.turn_id, turn_id); assert_eq!(completed.last_agent_message, None); + assert_eq!(completed.completed_at, Some(0)); + assert_eq!(completed.duration_ms, None); } #[test] @@ -1284,6 +1297,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }; @@ -1315,6 +1331,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1351,6 +1370,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), ) @@ -1453,12 +1475,18 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-interrupted".to_string(), items: Vec::new(), status: TurnStatus::Interrupted, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, Turn { id: "turn-failed".to_string(), @@ -1469,6 +1497,9 @@ mod tests { codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }), + started_at: None, + completed_at: None, + duration_ms: None, }, ], }, @@ -1481,7 +1512,10 @@ mod tests { assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); - let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, reason, .. + }) = &events[5].msg + else { panic!("expected interrupted turn replay"); }; assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); @@ -1528,6 +1562,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ false, ); @@ -1571,6 +1608,9 @@ mod tests { }], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, /*show_raw_agent_reasoning*/ true, ); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 63c8fe1249..6bae217362 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -676,6 +676,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08e8d237b5..d152e99f68 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1287,6 +1287,9 @@ mod tests { ], status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], }, model: "gpt-5.4".to_string(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c0df7b4b..786911f158 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5889,6 +5889,9 @@ impl ChatWidget { items, status, error, + started_at, + completed_at, + duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { self.last_non_retry_error = None; @@ -5909,6 +5912,9 @@ impl ChatWidget { items: Vec::new(), status, error, + started_at, + completed_at, + duration_ms, }, }, Some(replay_kind), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 2cabed2cc8..b5e9995d69 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -93,6 +93,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -132,6 +135,9 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -415,6 +421,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -450,6 +459,9 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { codex_error_info: None, additional_details: None, }), + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, @@ -471,6 +483,9 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -525,6 +540,9 @@ async fn live_app_server_server_overloaded_error_renders_warning() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b93..1bae5d2aa8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -618,6 +618,8 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1040,6 +1042,8 @@ async fn interrupt_restores_queued_messages_into_composer() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1079,6 +1083,8 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3cf..68a5659246 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -635,6 +635,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -649,6 +650,8 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final response.".into()), + completed_at: None, + duration_ms: None, }), }); @@ -667,6 +670,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -691,6 +695,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -756,6 +762,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -834,6 +842,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1259,6 +1269,8 @@ async fn interrupt_preserves_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1291,6 +1303,7 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1304,6 +1317,8 @@ async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1331,6 +1346,8 @@ async fn turn_complete_keeps_unified_exec_processes() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e8..4f43b498d7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -536,6 +536,9 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), Some(ReplayKind::ThreadSnapshot), @@ -686,6 +689,9 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -731,6 +737,7 @@ async fn replayed_turn_started_does_not_mark_task_running() { chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })]); @@ -747,6 +754,7 @@ async fn thread_snapshot_replayed_turn_started_marks_task_running() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -771,6 +779,9 @@ async fn replayed_in_progress_turn_marks_task_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: None, + completed_at: None, + duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); @@ -813,6 +824,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -853,6 +865,7 @@ async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state( chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -884,6 +897,7 @@ async fn replayed_interrupted_reconnect_footer_row_snapshot() { chat.replay_initial_messages(vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -909,6 +923,7 @@ async fn stream_recovery_restores_previous_status_header() { id: "task".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 9fed8177f1..8b1180cfa3 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -34,6 +34,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d1..29b1b8c591 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -566,6 +566,8 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -590,6 +592,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -602,6 +606,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -623,6 +629,8 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Plan details".to_string()), + completed_at: None, + duration_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); @@ -850,6 +858,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, }), /*replay_kind*/ None, @@ -893,6 +904,9 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a01..c42ec4fbac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -61,6 +61,8 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -146,6 +148,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -229,6 +232,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -248,6 +253,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -932,6 +939,8 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), }); @@ -1155,6 +1164,8 @@ async fn interrupt_exec_marks_failed_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1180,6 +1191,7 @@ async fn interrupted_turn_error_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1191,6 +1203,8 @@ async fn interrupted_turn_error_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1217,6 +1231,7 @@ async fn interrupted_turn_pending_steers_message_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1227,6 +1242,8 @@ async fn interrupted_turn_pending_steers_message_snapshot() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1323,6 +1340,8 @@ async fn review_ended_keeps_unified_exec_processes() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::ReviewEnded, + completed_at: None, + duration_ms: None, }), }); @@ -1355,6 +1374,7 @@ async fn enter_submits_steer_while_review_is_running() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1403,6 +1423,7 @@ async fn review_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c6fff59897..fdf7c5008b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -97,6 +97,8 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Final reply **markdown**".to_string()), + completed_at: None, + duration_ms: None, }), }); @@ -127,6 +129,8 @@ async fn slash_copy_state_tracks_plan_item_completion() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -160,6 +164,8 @@ async fn slash_copy_state_is_preserved_during_running_task() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Previous completed reply".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.on_task_started(); @@ -179,6 +185,8 @@ async fn slash_copy_state_clears_on_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Reply that will be rolled back".to_string()), + completed_at: None, + duration_ms: None, }), }); chat.handle_codex_event(Event { @@ -207,6 +215,8 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_ msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -232,6 +242,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -248,6 +259,8 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -277,6 +290,7 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { id: "turn-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -293,6 +307,8 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -656,6 +672,7 @@ async fn compact_queues_user_messages_snapshot() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba86..00be80d4e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -73,6 +73,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { id: "turn-start".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(950_000), collaboration_mode_kind: ModeKind::Default, }), @@ -628,6 +629,7 @@ async fn ui_snapshots_small_heights_task_running() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -661,6 +663,7 @@ async fn status_widget_and_approval_modal_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -723,6 +726,7 @@ async fn status_widget_active_snapshot() { id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -877,6 +881,8 @@ async fn status_line_branch_refreshes_after_turn_complete() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -895,6 +901,8 @@ async fn status_line_branch_refreshes_after_interrupt() { msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }); @@ -1120,6 +1128,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1142,6 +1151,8 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); @@ -1425,6 +1436,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1477,6 +1489,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), @@ -1551,6 +1564,8 @@ printf 'fenced within fenced\n' msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), }); for lines in drain_insert_history(&mut rx) { @@ -1572,6 +1587,7 @@ async fn chatwidget_tall() { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, }), From f9dea6ff99a697c3f49c48a70cd723548b9baadd Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 15:43:28 -0700 Subject: [PATCH 138/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 42 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1274 insertions(+), 44 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..cadab7ba38 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6efaf5680d..6973ffd0ae 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6617,6 +6617,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7393,6 +7402,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8e3c5be81c..b7af685d16 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..108166a4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } @@ -131,7 +149,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() "Start-Sleep -Seconds 10".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec![ + "python3".to_string(), + "-c".to_string(), + "import time; time.sleep(10)".to_string(), + ]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From f85b185d470d80a25f8f3d33f784de914145560a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 16:45:48 -0700 Subject: [PATCH 139/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 49 ++- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1279 insertions(+), 46 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..cadab7ba38 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cdbc4cb2c0..f048351d0a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6595,6 +6595,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7371,6 +7380,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4bcce5c7c9..9844b1b1a6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..1e982a07bd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,15 +131,28 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } #[tokio::test] async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> { + #[cfg(target_os = "windows")] + let shell_command = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 10".to_string(), + ]; + #[cfg(not(target_os = "windows"))] let shell_command = vec![ "python3".to_string(), "-c".to_string(), - "print(42)".to_string(), + "import time; time.sleep(10)".to_string(), ]; let tmp = TempDir::new()?; @@ -140,7 +165,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() shell_command.clone(), Some(&working_directory), Some(10_000), - "call_python_approval", + "call_sleep_approval", )?]) .await; create_config_toml(&codex_home, &server.uri(), "untrusted", "read-only")?; @@ -187,7 +212,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let ServerRequest::CommandExecutionRequestApproval { request_id, params } = request else { panic!("expected CommandExecutionRequestApproval request"); }; - assert_eq!(params.item_id, "call_python_approval"); + assert_eq!(params.item_id, "call_sleep_approval"); assert_eq!(params.thread_id, thread.id); assert_eq!(params.turn_id, turn.id); 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 79d7d4a95baf05a7c9cabaa9c297cb3f792660b4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 16:45:48 -0700 Subject: [PATCH 140/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index cadab7ba38..2381bb29b3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 3feeaf3b3b..fd7cf4249c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1338,8 +1338,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2192,6 +2198,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2202,6 +2209,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3987,6 +3999,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4041,6 +4054,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 3423ede1c072374bc2c26c8ef0088314066094c2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 16:45:48 -0700 Subject: [PATCH 141/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2381bb29b3..71b6b99ce0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a436323daa..03281f1f85 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2491,6 +2491,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3259,6 +3266,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 5b16aea7a2..8ccdb14b52 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 @@ -12083,6 +12083,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14644,6 +14651,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 03863d4bba..a65a24e2a5 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 @@ -9938,6 +9938,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12499,6 +12506,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2f8d30e46c..8e8e83c2d6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4004,6 +4005,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8331,6 +8337,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f048351d0a..4da8210da5 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6572,9 +6572,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..b103708d34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 48b68244f7d97f099bfb7fad88dfa34ecac777e9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 16:45:48 -0700 Subject: [PATCH 142/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 71b6b99ce0..19779c4c0b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 0e87522feffa1074e9d8bb526761e6aff345bf27 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 16:45:48 -0700 Subject: [PATCH 143/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 19779c4c0b..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4da8210da5..10a75b6afe 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9090,6 +9090,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 3328a11b87fee1bdf02f580ab8f117619ec100b8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 17:22:32 -0700 Subject: [PATCH 144/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 36 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1269 insertions(+), 43 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..cadab7ba38 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cdbc4cb2c0..f048351d0a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6595,6 +6595,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7371,6 +7380,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4bcce5c7c9..9844b1b1a6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8e328a84..bb2bdb8f0c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..708645caa0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } 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 b99d1cb73e..95b662f840 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +243,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..1b316af971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -670,6 +674,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + analytics_events_client, ) .await .map_err(|e| { @@ -809,6 +814,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1517,6 +1533,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1920,11 +1937,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2238,6 +2257,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6005,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6291,6 +6317,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 93d3eba021..28ecd4e359 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3a7ceb6f8c..a9190aea81 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7116878b47..961e76d79e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2275,6 +2275,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 9c6b36fb6881d6da2dd318ea371f4c45a00736bc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 17:22:32 -0700 Subject: [PATCH 145/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 193 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 38 ++++ codex-rs/analytics/src/facts.rs | 33 +++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 32 ++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++++--- codex-rs/core/src/codex.rs | 186 +++++++++++++++-- 8 files changed, 525 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 71b6b99ce0..19779c4c0b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -1079,7 +1085,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1131,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1153,90 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-2", + "expected_turn_id": "turn-2", + "accepted_turn_id": "turn-2", + "product_client_id": originator().value, + "num_input_images": 2, + "result": "accepted", + "rejection_reason": null, + "created_at": 1_716_000_123 + } + }) + ); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_steer_event", + "event_params": { + "thread_id": "thread-3", + "expected_turn_id": "turn-expected", + "accepted_turn_id": null, + "product_client_id": originator().value, + "num_input_images": 1, + "result": "rejected", + "rejection_reason": "expected_turn_mismatch", + "created_at": 1_716_000_124 + } + }) + ); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1274,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1288,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..931cfb9f79 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,24 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) product_client_id: String, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +285,22 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + product_client_id: originator().value, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..a7e8b817ee 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,6 +17,7 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; @@ -30,6 +32,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -88,6 +92,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +136,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -225,6 +233,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +387,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +413,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +429,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +445,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -488,6 +501,23 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params(&tracking, turn_steer), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +603,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From f5c978d126d31b68bc30642c2eab3930ef55e1b0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 17:22:32 -0700 Subject: [PATCH 146/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index cadab7ba38..2381bb29b3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 3feeaf3b3b..fd7cf4249c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1338,8 +1338,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2192,6 +2198,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2202,6 +2209,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3987,6 +3999,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4041,6 +4054,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 95b662f840..f79148ce43 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -309,6 +309,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From 81b291f2a973694b8d945a9b2377869a0751fa0f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 17:22:32 -0700 Subject: [PATCH 147/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 199 ++++++++++++++---- codex-rs/analytics/src/events.rs | 38 +++- codex-rs/analytics/src/facts.rs | 12 ++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 12 +- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 251 insertions(+), 53 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 19779c4c0b..a8dec8d9b9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1070,6 +1074,11 @@ fn turn_event_serializes_expected_shape() { turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1106,51 +1115,81 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, - json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } - }) + payload["event_params"]["product_client_id"], + json!("codex-tui") ); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1272,6 +1311,11 @@ async fn turn_lifecycle_emits_turn_event() { payload["event_params"]["product_client_id"], json!("codex-tui") ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1513,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 931cfb9f79..6839c4829f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -136,6 +129,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) turn_id: String, pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -364,3 +362,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a7e8b817ee..4072cca9d5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -30,6 +31,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerInput; @@ -567,6 +569,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -586,6 +591,11 @@ fn codex_turn_event_params( turn_id, product_client_id, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4da8210da5..10a75b6afe 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9090,6 +9090,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 997c10b5adaf4953ec8d202b3c37074da8dc3c3d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 17:22:32 -0700 Subject: [PATCH 148/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2381bb29b3..71b6b99ce0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a436323daa..03281f1f85 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2491,6 +2491,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3259,6 +3266,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 5b16aea7a2..8ccdb14b52 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 @@ -12083,6 +12083,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14644,6 +14651,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 03863d4bba..a65a24e2a5 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 @@ -9938,6 +9938,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12499,6 +12506,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 09c388337f..9b53219ea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,6 +64,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2f8d30e46c..8e8e83c2d6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4004,6 +4005,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8331,6 +8337,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f048351d0a..4da8210da5 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6572,9 +6572,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index bb2bdb8f0c..2254523063 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 f79148ce43..4ba72344a5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -304,7 +306,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -318,6 +322,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1539,6 +1643,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1572,6 +1677,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b316af971..164a9ed09d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -117,6 +118,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -886,6 +888,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -998,6 +1001,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1083,6 +1087,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1256,6 +1267,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1424,6 +1436,7 @@ impl Session { network: Option, environment: Arc, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1487,6 +1500,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2493,6 +2507,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2503,6 +2518,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2568,6 +2584,7 @@ impl Session { .map(StartedNetworkProxy::proxy), Arc::clone(&self.services.environment), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2680,6 +2697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4604,7 +4622,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4862,6 +4880,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4888,9 +4907,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5703,6 +5735,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6337,7 +6370,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9190aea81..cc3778742d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2800,6 +2800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3168,6 +3169,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3640,6 +3642,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, environment, "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index bc24974c82..b0e7b87d2c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 10957fd9f0..d854ce667f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -730,6 +730,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0148d62bfe..8648a082a7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1627,6 +1627,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1741,6 +1742,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 37ee27dd68..43ae44c760 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2338,6 +2338,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 663cf47488..647d840c2e 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 172738ffd1..f15a9574da 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5b77582db5..b103708d34 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 961e76d79e..04bacce8dd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -101,6 +101,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -299,6 +319,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -579,6 +603,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 786911f158..23ace423af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From c08e755619eef7b7405d03f2544c98685c04cf3c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 17:22:32 -0700 Subject: [PATCH 149/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 71b6b99ce0..80d33fb92d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -257,6 +263,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1079,7 +1104,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1150,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1172,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1483,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1497,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..998e3b40ed 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -56,6 +62,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -88,6 +95,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +139,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -194,6 +205,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -225,6 +238,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +392,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +418,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +434,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +450,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -466,6 +484,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -488,6 +508,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +646,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 164a9ed09d..d65666935d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4078,47 +4108,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4783,6 +4929,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4943,11 +5090,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4958,10 +5108,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From e816560966d2f52f5bd0ac88034cd54b2b72ead9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 21:18:22 -0700 Subject: [PATCH 150/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 314 insertions(+), 77 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 80d33fb92d..77268cefb7 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -266,8 +270,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -549,8 +553,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1087,8 +1091,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1125,51 +1135,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1478,9 +1534,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1678,6 +1755,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1703,12 +1847,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 998e3b40ed..ae37e44777 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -174,8 +176,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -572,16 +574,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -592,7 +602,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -610,6 +621,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -627,8 +641,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4da8210da5..10a75b6afe 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9090,6 +9090,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 4ba72344a5..cbaf943c7a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -307,6 +307,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d65666935d..ca341e8397 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -680,6 +681,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1124,6 +1126,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1185,6 +1195,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1209,6 +1220,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1584,6 +1596,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6505,6 +6519,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6526,6 +6544,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cc3778742d..7c14e86009 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1871,6 +1872,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1973,6 +1975,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2322,6 +2325,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2588,6 +2592,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3532,6 +3538,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 252b1b6ae3..322462306d 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -41,6 +42,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From a860b59e5d88b2f1ac172c1827b383fa49de65b7 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 21:40:43 -0700 Subject: [PATCH 151/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 31 ++ .../app-server/src/codex_message_processor.rs | 10 + codex-rs/app-server/src/message_processor.rs | 22 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 36 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++++ codex-rs/core/src/codex.rs | 72 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 23 + codex-rs/protocol/src/protocol.rs | 8 + 21 files changed, 1269 insertions(+), 43 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..cadab7ba38 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..3feeaf3b3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, @@ -201,6 +203,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +221,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1720,6 +1725,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + Some(&analytics_events_client), &outgoing, &thread_state, ) @@ -1897,6 +1903,7 @@ 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 { @@ -1911,6 +1918,10 @@ 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; @@ -2103,6 +2114,7 @@ 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>, ) { @@ -2123,6 +2135,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2145,7 @@ 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>, ) { @@ -2147,6 +2161,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2900,7 @@ 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; @@ -3006,6 +3022,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3037,7 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), + self.analytics_events_client.clone(), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), @@ -3328,6 +3346,13 @@ 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(), }; @@ -3736,6 +3761,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3810,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3858,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4125,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4147,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4159,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 91641898d1..41050539ed 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context.analytics_events_client.clone(), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15df5a2a54..bcc34d47da 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,7 +219,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let thread_manager = Arc::new(ThreadManager::new( + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); + let thread_manager = Arc::new(ThreadManager::new_with_analytics_events_client( config.as_ref(), auth_manager.clone(), session_source, @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..3a76ad8aa2 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..708645caa0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } 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 e8f3e1df2c..0213810025 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,9 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -335,6 +340,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 237b77e38d..b21c1fef1d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -185,6 +186,7 @@ use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -678,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -817,6 +822,17 @@ pub(crate) fn session_loop_termination_from_handle( .shared() } +fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -1532,6 +1548,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1936,11 +1953,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2257,6 +2276,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6035,6 +6059,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6321,6 +6347,42 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ab6324efa2..aba82d2d54 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index b0437edbfe..ae61cc0777 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2626,6 +2626,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..8941c14c58 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,24 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + ) -> Self { + Self::new_with_analytics_events_client( + config, + auth_manager, + session_source, + collaboration_modes_config, + environment_manager, + /*analytics_events_client*/ None, + ) + } + + pub fn new_with_analytics_events_client( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + collaboration_modes_config: CollaborationModesConfig, + environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +277,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +347,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +889,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 05926a9544..0f4cd84e2f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2277,6 +2277,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From d6753245d655e579d04354ebf974bce437414367 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 21:40:43 -0700 Subject: [PATCH 152/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 72 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/reducer.rs | 34 +++++++++ .../app-server/src/bespoke_event_handling.rs | 18 ++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index cadab7ba38..2381bb29b3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,9 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -181,6 +184,33 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_thread_token_usage_updated_notification( + thread_id: &str, + turn_id: &str, +) -> ServerNotification { + ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 500, + input_tokens: 200, + cached_input_tokens: 50, + output_tokens: 220, + reasoning_output_tokens: 30, + }, + last: TokenUsageBreakdown { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + model_context_window: Some(200_000), + }, + }) +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +262,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +332,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Notification(Box::new( + sample_thread_token_usage_updated_notification("thread-2", "turn-2"), + )), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1087,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1133,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1157,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1186,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1207,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1237,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1265,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1283,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..60519ab6b8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -85,6 +86,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -221,6 +223,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -373,6 +376,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +401,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -404,6 +409,18 @@ impl AnalyticsReducer { .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::ThreadTokenUsageUpdated(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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.token_usage = Some(notification.token_usage.last); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -414,6 +431,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +550,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +582,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 3feeaf3b3b..fd7cf4249c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1338,8 +1338,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + Some(&analytics_events_client), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2192,6 +2198,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2202,6 +2209,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3987,6 +3999,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4041,6 +4054,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 0213810025..b0a8b89ca9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -406,6 +406,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } From b09d3612d31e35f1fbe69f76216321b4a8872985 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 21:40:43 -0700 Subject: [PATCH 153/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 106 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 +++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2381bb29b3..71b6b99ce0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -240,7 +241,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1062,7 +1063,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,7 +1109,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1196,6 +1197,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9f1c46d800..f325c3825e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2510,6 +2510,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3278,6 +3285,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 80f2b226b7..65139a5ff6 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 @@ -12201,6 +12201,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14762,6 +14769,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 eb4e9d7b18..58931cee6d 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 @@ -10056,6 +10056,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12617,6 +12624,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3562d9cda6..1836a1e08e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8417,6 +8423,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 41050539ed..48ce9849e1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6647,9 +6647,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 3a76ad8aa2..7d4cd0c128 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 b0a8b89ca9..0a2cf27568 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -68,6 +69,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -401,7 +403,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -415,6 +419,106 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + + Ok(()) +} + #[tokio::test] async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { let server = create_mock_responses_server_sequence(vec![String::new()]).await; @@ -1636,6 +1740,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1669,6 +1774,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b21c1fef1d..0324a6e459 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -118,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -894,6 +896,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1012,6 +1015,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1097,6 +1101,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1270,6 +1281,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1438,6 +1450,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1502,6 +1515,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2512,6 +2526,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2522,6 +2537,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2587,6 +2603,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2699,6 +2716,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4633,7 +4651,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4891,6 +4909,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4917,9 +4936,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5733,6 +5765,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6367,7 +6400,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae61cc0777..055b83b952 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2806,6 +2806,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3174,6 +3175,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3646,6 +3648,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..4c2d02744d 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -759,6 +759,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cb97e4a3b3..c062ac55ee 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1668,6 +1668,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1782,6 +1783,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 346e8e9e5e..d241ee3023 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0f4cd84e2f..aca5c38b87 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -246,6 +253,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -301,6 +321,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -581,6 +605,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f942308aa7..e087462f4d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 68a8e6e26ae32d0da62ad88d291fe913740dbcd8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 21:40:43 -0700 Subject: [PATCH 154/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 71b6b99ce0..80d33fb92d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -257,6 +263,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1079,7 +1104,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1125,7 +1150,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1147,6 +1172,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1184,6 +1483,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1197,6 +1497,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..e78f944873 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +198,15 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..a9b019d841 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -73,6 +73,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -133,6 +160,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -140,6 +168,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..69eab1da09 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 60519ab6b8..998e3b40ed 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -56,6 +62,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -88,6 +95,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -131,6 +139,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -194,6 +205,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -225,6 +238,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -378,6 +392,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -403,6 +418,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -418,6 +434,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.token_usage = Some(notification.token_usage.last); } @@ -433,6 +450,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -466,6 +484,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -488,6 +508,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -573,7 +646,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0324a6e459..3bb1fb2ac1 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4107,47 +4137,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4812,6 +4958,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4972,11 +5119,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4987,10 +5137,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 90aeef162609ada827b4e88ceb9ba17c8fa6244d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 6 Apr 2026 21:40:43 -0700 Subject: [PATCH 155/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 + codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 314 insertions(+), 77 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 80d33fb92d..77268cefb7 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -75,6 +75,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -248,6 +249,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -266,8 +270,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -549,8 +553,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1087,8 +1091,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1125,51 +1135,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1478,9 +1534,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1678,6 +1755,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1703,12 +1847,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a9b019d841..19b5bf8f48 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -44,6 +45,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -65,6 +69,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 69eab1da09..eb4fe48683 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 998e3b40ed..ae37e44777 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -174,8 +176,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -572,16 +574,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -592,7 +602,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -610,6 +621,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -627,8 +641,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 48ce9849e1..e87bb3bcab 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9165,6 +9165,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 0a2cf27568..db723323ec 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -404,6 +404,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3bb1fb2ac1..329499b72f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -688,6 +689,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1138,6 +1140,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1199,6 +1209,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1223,6 +1234,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1599,6 +1611,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6535,6 +6549,10 @@ async fn track_turn_resolved_config_analytics( turn_context: &TurnContext, input: &[UserInput], ) { + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6556,6 +6574,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 055b83b952..5c828db59a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1875,6 +1876,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1977,6 +1979,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2326,6 +2329,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2592,6 +2596,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2696,6 +2701,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3538,6 +3544,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From af2561da23a245d065db1daf41b8121ca7158a1a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 11:28:00 -0700 Subject: [PATCH 156/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 398 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 ++++++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 31 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++ .../tests/suite/v2/turn_interrupt.rs | 36 +- .../app-server/tests/suite/v2/turn_start.rs | 209 +++++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 26 files changed, 1337 insertions(+), 42 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..cadab7ba38 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,215 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..ec42f0b611 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2902,7 @@ 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; @@ -3006,6 +3024,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3039,7 @@ 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(), @@ -3328,6 +3348,13 @@ 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(), }; @@ -3736,6 +3763,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3812,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3860,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4127,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4149,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4161,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 91641898d1..4c19fba551 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15df5a2a54..03c5a1ce96 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,6 +219,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..f8dbaaf920 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,34 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[features] +general_analytics = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..3a76ad8aa2 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -120,6 +120,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..708645caa0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,8 +3,10 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -22,6 +24,9 @@ use codex_app_server_protocol::UserInput as V2UserInput; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -43,14 +48,20 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +98,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +108,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = timeout( @@ -119,6 +131,12 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["turn_id"], turn_id); + assert_eq!(event["event_params"]["status"], "interrupted"); + assert_eq!(event["event_params"]["turn_error"], serde_json::Value::Null); + Ok(()) } 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 e8f3e1df2c..e45ea0fcf0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -335,6 +341,209 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_failed_turn_event_analytics() -> Result<()> { + let server = create_mock_responses_server_sequence(vec![String::new()]).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "trigger failed turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.turn.status, TurnStatus::Failed); + assert!(completed.turn.error.is_some()); + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!(event["event_params"]["status"], "failed"); + assert_ne!(event["event_params"]["turn_error"], serde_json::Value::Null); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 237b77e38d..d8ab2e055c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -678,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1532,6 +1537,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1936,11 +1942,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2257,6 +2265,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6035,6 +6048,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6321,6 +6336,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ab6324efa2..aba82d2d54 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index b0437edbfe..ae61cc0777 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2626,6 +2626,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..e4e7a9b0ec 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +260,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +330,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +872,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..e3f6e3c59f 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -248,6 +248,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let thread_1 = manager .start_thread(config.clone()) @@ -299,6 +300,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +437,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +540,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cb97e4a3b3..51ade5f464 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1122,6 +1122,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 05926a9544..0f4cd84e2f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2277,6 +2277,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 6e02a3f5e6289b3fe7bd4b48009c4ae2414a2181 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 11:39:38 -0700 Subject: [PATCH 157/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 58 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 18 +++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 9 files changed, 155 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index cadab7ba38..c5541560fb 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1251,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1269,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..ed5d96bb25 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSubmissionType { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..5a4630ff47 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSubmissionType; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ec42f0b611..312d90d31e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + analytics_events_client.as_ref(), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2194,6 +2200,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2204,6 +2211,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3989,6 +4001,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4043,6 +4056,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 e45ea0fcf0..9d8fecc40e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -407,6 +407,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f33e6886f4..fadf8d9d9f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 9976d499926a26a2ae518b7f2c1efa2414e5f31f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 11:41:54 -0700 Subject: [PATCH 158/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c5541560fb..0551687fd8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9f1c46d800..f325c3825e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2510,6 +2510,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3278,6 +3285,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 80f2b226b7..65139a5ff6 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 @@ -12201,6 +12201,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14762,6 +14769,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 eb4e9d7b18..58931cee6d 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 @@ -10056,6 +10056,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12617,6 +12624,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3562d9cda6..1836a1e08e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8417,6 +8423,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4c19fba551..636be2e981 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6647,9 +6647,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 3a76ad8aa2..7d4cd0c128 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -155,6 +155,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 9d8fecc40e..db65875af1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -402,7 +404,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -479,6 +483,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1703,6 +1808,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1736,6 +1842,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d8ab2e055c..6dddc247e0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -118,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -883,6 +885,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1001,6 +1004,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1086,6 +1090,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1259,6 +1270,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1427,6 +1439,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1491,6 +1504,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2501,6 +2515,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2511,6 +2526,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2576,6 +2592,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2688,6 +2705,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4622,7 +4640,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4880,6 +4898,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4906,9 +4925,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5722,6 +5754,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6360,7 +6393,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae61cc0777..055b83b952 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2806,6 +2806,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3174,6 +3175,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3646,6 +3648,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 51ade5f464..0a966692e6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1669,6 +1669,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1783,6 +1784,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 346e8e9e5e..d241ee3023 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0f4cd84e2f..aca5c38b87 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -246,6 +253,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -301,6 +321,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -581,6 +605,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f942308aa7..e087462f4d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 150f8f0a7ea9a4598055fcf3324001ff6608073c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 11:44:34 -0700 Subject: [PATCH 159/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0551687fd8..7361cee099 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..6c11b06c28 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6dddc247e0..81bc3d7141 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4096,47 +4126,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4801,6 +4947,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4961,11 +5108,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4976,10 +5126,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 858c958a1c71ec1919a7018f0b208e18e3c1fea4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 11:51:14 -0700 Subject: [PATCH 160/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7361cee099..4a220ce21f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -73,6 +73,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -234,6 +235,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -252,8 +256,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -535,8 +539,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1073,8 +1077,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1111,51 +1121,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1464,9 +1520,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1664,6 +1741,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1689,12 +1833,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 6c11b06c28..9b719cb3d4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 636be2e981..389b71cc39 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9167,6 +9167,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 db65875af1..4ef52203d2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -398,13 +398,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 81bc3d7141..776cdadb9d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -688,6 +689,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1127,6 +1129,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1188,6 +1198,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1212,6 +1223,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1588,6 +1600,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6528,6 +6542,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6549,6 +6567,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 055b83b952..5c828db59a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1875,6 +1876,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1977,6 +1979,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2326,6 +2329,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2592,6 +2596,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2696,6 +2701,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3538,6 +3544,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From fc8860cfed3d20f300b04a50fd4df7b3504088d6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 161/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 462 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 49 ++ .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 26 files changed, 1330 insertions(+), 42 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..728a44ace0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,279 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..ec42f0b611 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2902,7 @@ 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; @@ -3006,6 +3024,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3039,7 @@ 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(), @@ -3328,6 +3348,13 @@ 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(), }; @@ -3736,6 +3763,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3812,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3860,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4127,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4149,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4161,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 91641898d1..4c19fba551 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15df5a2a54..03c5a1ce96 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,6 +219,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..babf6396d8 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,20 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +134,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 e8f3e1df2c..5645a4d3a8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -335,6 +341,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 237b77e38d..d8ab2e055c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -678,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1532,6 +1537,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1936,11 +1942,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2257,6 +2265,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6035,6 +6048,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6321,6 +6336,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ab6324efa2..aba82d2d54 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index b0437edbfe..ae61cc0777 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2626,6 +2626,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..e4e7a9b0ec 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +260,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +330,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +872,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cb97e4a3b3..51ade5f464 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1122,6 +1122,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 05926a9544..0f4cd84e2f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2277,6 +2277,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From d3f41f1b2cc27a9a1a7acd8fd2a1f3fe23ef9c0c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 162/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 58 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 18 +++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 9 files changed, 155 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 728a44ace0..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1266,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1283,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..ed5d96bb25 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSubmissionType { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..5a4630ff47 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSubmissionType; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ec42f0b611..312d90d31e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + analytics_events_client.as_ref(), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2194,6 +2200,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2204,6 +2211,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3989,6 +4001,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4043,6 +4056,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5645a4d3a8..3381f7ad68 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -407,6 +407,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f33e6886f4..fadf8d9d9f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From ac4d18c4af116267ec048c8413c70efddd436d62 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 163/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..d026aeaac8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9f1c46d800..f325c3825e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2510,6 +2510,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3278,6 +3285,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 80f2b226b7..65139a5ff6 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 @@ -12201,6 +12201,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14762,6 +14769,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 eb4e9d7b18..58931cee6d 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 @@ -10056,6 +10056,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12617,6 +12624,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3562d9cda6..1836a1e08e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8417,6 +8423,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4c19fba551..636be2e981 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6647,9 +6647,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index babf6396d8..6c74b3dfb4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -169,6 +169,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3381f7ad68..a503c3087c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -402,7 +404,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -479,6 +483,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1636,6 +1741,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1669,6 +1775,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d8ab2e055c..6dddc247e0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -118,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -883,6 +885,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1001,6 +1004,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1086,6 +1090,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1259,6 +1270,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1427,6 +1439,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1491,6 +1504,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2501,6 +2515,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2511,6 +2526,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2576,6 +2592,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2688,6 +2705,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4622,7 +4640,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4880,6 +4898,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4906,9 +4925,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5722,6 +5754,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6360,7 +6393,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae61cc0777..055b83b952 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2806,6 +2806,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3174,6 +3175,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3646,6 +3648,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 51ade5f464..0a966692e6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1669,6 +1669,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1783,6 +1784,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 346e8e9e5e..d241ee3023 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0f4cd84e2f..aca5c38b87 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -246,6 +253,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -301,6 +321,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -581,6 +605,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f942308aa7..e087462f4d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 48c5be90cb6f9a36e470b004e09c307b8c27dfa8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 164/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..ffa1aa489c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..6c11b06c28 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6dddc247e0..81bc3d7141 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4096,47 +4126,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4801,6 +4947,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4961,11 +5108,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4976,10 +5126,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 07d8b78a495886650f8e20ac50a1eed5228aa253 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 165/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ffa1aa489c..d087518ab9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -73,6 +73,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -234,6 +235,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -252,8 +256,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -535,8 +539,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1073,8 +1077,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1111,51 +1121,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1464,9 +1520,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1664,6 +1741,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1689,12 +1833,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 6c11b06c28..9b719cb3d4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 636be2e981..389b71cc39 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9167,6 +9167,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 a503c3087c..b6f6d60f5e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -398,13 +398,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 81bc3d7141..776cdadb9d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -688,6 +689,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1127,6 +1129,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1188,6 +1198,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1212,6 +1223,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1588,6 +1600,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6528,6 +6542,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6549,6 +6567,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 055b83b952..5c828db59a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1875,6 +1876,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1977,6 +1979,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2326,6 +2329,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2592,6 +2596,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2696,6 +2701,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3538,6 +3544,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 1d220d93257f12cfbe08f5ae8595cce3b6fe3a1e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 166/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 462 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 49 ++ .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 27 files changed, 1331 insertions(+), 42 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..728a44ace0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,279 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..ec42f0b611 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2902,7 @@ 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; @@ -3006,6 +3024,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3039,7 @@ 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(), @@ -3328,6 +3348,13 @@ 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(), }; @@ -3736,6 +3763,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3812,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3860,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4127,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4149,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4161,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 91641898d1..4c19fba551 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15df5a2a54..03c5a1ce96 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,6 +219,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..babf6396d8 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,20 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +134,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 e8f3e1df2c..5645a4d3a8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -335,6 +341,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 237b77e38d..d8ab2e055c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -678,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1532,6 +1537,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1936,11 +1942,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2257,6 +2265,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6035,6 +6048,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6321,6 +6336,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ab6324efa2..aba82d2d54 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index b0437edbfe..ae61cc0777 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2626,6 +2626,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..e4e7a9b0ec 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +260,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +330,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +872,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cb97e4a3b3..51ade5f464 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1122,6 +1122,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 05926a9544..0f4cd84e2f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2277,6 +2277,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From d3f463dce08f5c82cb29e8cf343e82d1bc1d2d08 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 167/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 58 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 18 +++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 9 files changed, 155 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 728a44ace0..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1266,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1283,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..ed5d96bb25 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSubmissionType { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..5a4630ff47 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSubmissionType; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ec42f0b611..312d90d31e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + analytics_events_client.as_ref(), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2194,6 +2200,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2204,6 +2211,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3989,6 +4001,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4043,6 +4056,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5645a4d3a8..3381f7ad68 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -407,6 +407,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f33e6886f4..fadf8d9d9f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 8377066b8a78a5ed63dded0abcb4a4d0dc66641c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 168/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..d026aeaac8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9f1c46d800..f325c3825e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2510,6 +2510,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3278,6 +3285,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 80f2b226b7..65139a5ff6 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 @@ -12201,6 +12201,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14762,6 +14769,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 eb4e9d7b18..58931cee6d 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 @@ -10056,6 +10056,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12617,6 +12624,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3562d9cda6..1836a1e08e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8417,6 +8423,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4c19fba551..636be2e981 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6647,9 +6647,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index babf6396d8..6c74b3dfb4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -169,6 +169,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3381f7ad68..a503c3087c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -402,7 +404,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -479,6 +483,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1636,6 +1741,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1669,6 +1775,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d8ab2e055c..6dddc247e0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -118,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -883,6 +885,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1001,6 +1004,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1086,6 +1090,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1259,6 +1270,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1427,6 +1439,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1491,6 +1504,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2501,6 +2515,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2511,6 +2526,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2576,6 +2592,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2688,6 +2705,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4622,7 +4640,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4880,6 +4898,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4906,9 +4925,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5722,6 +5754,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6360,7 +6393,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae61cc0777..055b83b952 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2806,6 +2806,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3174,6 +3175,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3646,6 +3648,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 51ade5f464..0a966692e6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1669,6 +1669,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1783,6 +1784,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 346e8e9e5e..d241ee3023 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0f4cd84e2f..aca5c38b87 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -246,6 +253,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -301,6 +321,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -581,6 +605,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f942308aa7..e087462f4d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 55bbfccf6f47d18c95b4042551aa6b01334aeaee Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 169/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..ffa1aa489c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..6c11b06c28 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6dddc247e0..81bc3d7141 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4096,47 +4126,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4801,6 +4947,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4961,11 +5108,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4976,10 +5126,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 075607294459b41d1dc272f4d0886e66c0ae1f07 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:20:29 -0700 Subject: [PATCH 170/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ffa1aa489c..d087518ab9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -73,6 +73,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -234,6 +235,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -252,8 +256,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -535,8 +539,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1073,8 +1077,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1111,51 +1121,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1464,9 +1520,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1664,6 +1741,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1689,12 +1833,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 6c11b06c28..9b719cb3d4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 636be2e981..389b71cc39 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9167,6 +9167,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 a503c3087c..b6f6d60f5e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -398,13 +398,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 81bc3d7141..776cdadb9d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -688,6 +689,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1127,6 +1129,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1188,6 +1198,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1212,6 +1223,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1588,6 +1600,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6528,6 +6542,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6549,6 +6567,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 055b83b952..5c828db59a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1875,6 +1876,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1977,6 +1979,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2326,6 +2329,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2592,6 +2596,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2696,6 +2701,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3538,6 +3544,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 2d123b1e228b01b8a88fff027ecc735b4c22b655 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:44:11 -0700 Subject: [PATCH 171/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 460 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 49 ++ .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 27 files changed, 1329 insertions(+), 42 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..1fe77760ee 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,277 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..ec42f0b611 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2902,7 @@ 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; @@ -3006,6 +3024,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3039,7 @@ 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(), @@ -3328,6 +3348,13 @@ 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(), }; @@ -3736,6 +3763,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3812,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3860,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4127,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4149,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4161,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 91641898d1..4c19fba551 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15df5a2a54..03c5a1ce96 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,6 +219,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..babf6396d8 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,20 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +134,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 e8f3e1df2c..5645a4d3a8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -335,6 +341,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 237b77e38d..d8ab2e055c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -678,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1532,6 +1537,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1936,11 +1942,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2257,6 +2265,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6035,6 +6048,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6321,6 +6336,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ab6324efa2..aba82d2d54 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index b0437edbfe..ae61cc0777 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2626,6 +2626,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..e4e7a9b0ec 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +260,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +330,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +872,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cb97e4a3b3..51ade5f464 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1122,6 +1122,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 05926a9544..0f4cd84e2f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2277,6 +2277,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 1c6c912361784e3450b3970e35940ea8632c39f3 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:44:14 -0700 Subject: [PATCH 172/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 18 +++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 9 files changed, 157 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1251,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1233,6 +1283,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1264,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1281,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..ed5d96bb25 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSubmissionType { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..5a4630ff47 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSubmissionType; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ec42f0b611..312d90d31e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + analytics_events_client.as_ref(), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2194,6 +2200,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2204,6 +2211,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3989,6 +4001,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4043,6 +4056,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5645a4d3a8..3381f7ad68 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -407,6 +407,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f33e6886f4..fadf8d9d9f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From b28033e2a7f81cdd6b0e7f92dc72cef84ee7128c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:44:14 -0700 Subject: [PATCH 173/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..d026aeaac8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9f1c46d800..f325c3825e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2510,6 +2510,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3278,6 +3285,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 80f2b226b7..65139a5ff6 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 @@ -12201,6 +12201,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14762,6 +14769,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 eb4e9d7b18..58931cee6d 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 @@ -10056,6 +10056,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12617,6 +12624,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3562d9cda6..1836a1e08e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8417,6 +8423,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4c19fba551..636be2e981 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6647,9 +6647,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index babf6396d8..6c74b3dfb4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -169,6 +169,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3381f7ad68..a503c3087c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -402,7 +404,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -479,6 +483,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1636,6 +1741,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1669,6 +1775,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d8ab2e055c..6dddc247e0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -118,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -883,6 +885,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1001,6 +1004,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1086,6 +1090,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1259,6 +1270,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1427,6 +1439,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1491,6 +1504,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2501,6 +2515,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2511,6 +2526,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2576,6 +2592,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2688,6 +2705,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4622,7 +4640,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4880,6 +4898,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4906,9 +4925,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5722,6 +5754,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6360,7 +6393,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae61cc0777..055b83b952 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2806,6 +2806,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3174,6 +3175,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3646,6 +3648,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 51ade5f464..0a966692e6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1669,6 +1669,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1783,6 +1784,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 346e8e9e5e..d241ee3023 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0f4cd84e2f..aca5c38b87 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -246,6 +253,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -301,6 +321,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -581,6 +605,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f942308aa7..e087462f4d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From eae8f2dd502f803b82990f0d6ad4e6fbe9de0e7f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:44:14 -0700 Subject: [PATCH 174/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..ffa1aa489c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..6c11b06c28 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6dddc247e0..81bc3d7141 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4096,47 +4126,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4801,6 +4947,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4961,11 +5108,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4976,10 +5126,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 4eb5621788d4842bd844d4ac0f0da04c9f1b58f8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 12:44:14 -0700 Subject: [PATCH 175/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ffa1aa489c..d087518ab9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -73,6 +73,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -234,6 +235,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -252,8 +256,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -535,8 +539,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1073,8 +1077,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1111,51 +1121,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1464,9 +1520,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1664,6 +1741,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1689,12 +1833,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 6c11b06c28..9b719cb3d4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 636be2e981..389b71cc39 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9167,6 +9167,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 a503c3087c..b6f6d60f5e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -398,13 +398,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 81bc3d7141..776cdadb9d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -688,6 +689,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1127,6 +1129,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1188,6 +1198,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1212,6 +1223,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1588,6 +1600,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6528,6 +6542,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6549,6 +6567,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 055b83b952..5c828db59a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1875,6 +1876,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1977,6 +1979,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2326,6 +2329,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2592,6 +2596,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2696,6 +2701,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3538,6 +3544,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From f93ffcd3a5bd6bdf158150bba35be767299d1091 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 13:55:44 -0700 Subject: [PATCH 176/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 460 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1336 insertions(+), 45 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..1fe77760ee 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,277 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 141ee78bdb..ec42f0b611 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2885,6 +2902,7 @@ 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; @@ -3006,6 +3024,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3020,6 +3039,7 @@ 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(), @@ -3328,6 +3348,13 @@ 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(), }; @@ -3736,6 +3763,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3784,6 +3812,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3831,6 +3860,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4097,6 +4127,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4118,6 +4149,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4129,6 +4161,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 91641898d1..4c19fba551 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15df5a2a54..03c5a1ce96 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -219,6 +219,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -229,12 +234,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -671,6 +672,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 e8f3e1df2c..5645a4d3a8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -335,6 +341,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 237b77e38d..d8ab2e055c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -51,6 +51,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -430,6 +432,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -484,6 +487,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -678,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1532,6 +1537,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1936,11 +1942,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2257,6 +2265,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6035,6 +6048,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6321,6 +6336,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ab6324efa2..aba82d2d54 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index b0437edbfe..ae61cc0777 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2626,6 +2626,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6d93a1a329..e4e7a9b0ec 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -208,6 +209,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -219,6 +221,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -257,6 +260,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -326,6 +330,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -867,6 +872,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cb97e4a3b3..51ade5f464 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1122,6 +1122,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 05926a9544..0f4cd84e2f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2277,6 +2277,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 0bb2e8df6aac0a3c0b670443f8e9d64937f57768 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 13:55:46 -0700 Subject: [PATCH 177/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 18 +++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 9 files changed, 157 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1251,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1233,6 +1283,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1264,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1281,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..ed5d96bb25 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSubmissionType { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..5a4630ff47 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSubmissionType; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ec42f0b611..312d90d31e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + analytics_events_client.as_ref(), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2194,6 +2200,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2204,6 +2211,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3989,6 +4001,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4043,6 +4056,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 5645a4d3a8..3381f7ad68 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -407,6 +407,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f33e6886f4..fadf8d9d9f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 2d7646326c5542ffa860201f1a51ac11153e5d92 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 13:55:46 -0700 Subject: [PATCH 178/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 26 +++++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..d026aeaac8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9f1c46d800..f325c3825e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2510,6 +2510,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3278,6 +3285,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 80f2b226b7..65139a5ff6 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 @@ -12201,6 +12201,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14762,6 +14769,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 eb4e9d7b18..58931cee6d 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 @@ -10056,6 +10056,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12617,6 +12624,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3562d9cda6..1836a1e08e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8417,6 +8423,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4c19fba551..636be2e981 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6647,9 +6647,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 2e8781606f..504b33cd8a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -614,6 +614,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..d0fe9bbeb9 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -173,6 +173,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 3381f7ad68..a503c3087c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -402,7 +404,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -479,6 +483,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1636,6 +1741,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1669,6 +1775,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d8ab2e055c..6dddc247e0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -118,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -883,6 +885,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1001,6 +1004,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1086,6 +1090,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1259,6 +1270,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1427,6 +1439,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1491,6 +1504,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2501,6 +2515,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2511,6 +2526,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2576,6 +2592,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2688,6 +2705,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4622,7 +4640,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4880,6 +4898,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4906,9 +4925,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5722,6 +5754,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6360,7 +6393,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae61cc0777..055b83b952 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2806,6 +2806,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3174,6 +3175,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3646,6 +3648,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 51ade5f464..0a966692e6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1669,6 +1669,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1783,6 +1784,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f35152e185..e3a0a8e54c 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -404,6 +404,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2f72277086..8039f39855 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +335,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -417,6 +419,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -503,6 +506,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -601,6 +605,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -681,6 +686,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -762,6 +768,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -897,6 +904,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1039,6 +1047,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1204,6 +1213,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1317,6 +1327,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1440,6 +1451,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1535,6 +1547,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1621,6 +1634,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1757,6 +1771,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1930,6 +1945,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2012,6 +2028,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2105,6 +2122,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2214,6 +2232,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2354,6 +2373,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2473,6 +2493,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2574,6 +2595,7 @@ PY service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2675,6 +2697,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2784,6 +2807,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2884,6 +2908,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -3024,6 +3049,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f16d3c2b18..f9ac77748c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 346e8e9e5e..d241ee3023 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -689,6 +689,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0f4cd84e2f..aca5c38b87 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -246,6 +253,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -301,6 +321,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -581,6 +605,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cf42e85160..9437b6fc67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2234,6 +2234,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2316,6 +2317,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d152e99f68..b4c287af7c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -82,6 +82,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -425,6 +426,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -443,6 +445,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f942308aa7..e087462f4d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5567,6 +5568,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5782,6 +5791,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7243,8 +7253,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 06ecdc03d656e965b5c765f28d7365b646850141 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 13:55:46 -0700 Subject: [PATCH 179/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..ffa1aa489c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..6c11b06c28 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6dddc247e0..81bc3d7141 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,9 +49,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4096,47 +4126,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4801,6 +4947,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4961,11 +5108,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4976,10 +5126,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 3e672f533b6cfc8c98c9d65a91890edfac9af146 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 13:55:46 -0700 Subject: [PATCH 180/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ffa1aa489c..d087518ab9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -73,6 +73,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -234,6 +235,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -252,8 +256,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -535,8 +539,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1073,8 +1077,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1111,51 +1121,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1464,9 +1520,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1664,6 +1741,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1689,12 +1833,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 6c11b06c28..9b719cb3d4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 636be2e981..389b71cc39 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9167,6 +9167,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 a503c3087c..b6f6d60f5e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -398,13 +398,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 81bc3d7141..776cdadb9d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -688,6 +689,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1127,6 +1129,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1188,6 +1198,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1212,6 +1223,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1588,6 +1600,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6528,6 +6542,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6549,6 +6567,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 055b83b952..5c828db59a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::mcp_connection_manager::ToolInfo; @@ -1875,6 +1876,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1977,6 +1979,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2326,6 +2329,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2592,6 +2596,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2696,6 +2701,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3538,6 +3544,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From df708eeaa5823b4d7ab5cac23a955ab35c29df9c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:05:04 -0700 Subject: [PATCH 181/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 18 +++++- .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 9 files changed, 157 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1251,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1233,6 +1283,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1264,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1281,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..ed5d96bb25 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSubmissionType { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..5a4630ff47 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSubmissionType; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b656df0207..c50e8dfa13 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1340,8 +1340,14 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::TokenCount(token_count_event) => { - handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) - .await; + handle_token_count_event( + conversation_id, + event_turn_id, + analytics_events_client.as_ref(), + token_count_event, + &outgoing, + ) + .await; } EventMsg::Error(ev) => { thread_watch_manager @@ -2194,6 +2200,7 @@ async fn handle_thread_rollback_failed( async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, + analytics_events_client: Option<&AnalyticsEventsClient>, token_count_event: TokenCountEvent, outgoing: &ThreadScopedOutgoingMessageSender, ) { @@ -2204,6 +2211,11 @@ async fn handle_token_count_event( turn_id, token_usage, }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_notification( + ServerNotification::ThreadTokenUsageUpdated(notification.clone()), + ); + } outgoing .send_server_notification(ServerNotification::ThreadTokenUsageUpdated(notification)) .await; @@ -3990,6 +4002,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: Some(info), rate_limits: Some(rate_limits), @@ -4044,6 +4057,7 @@ mod tests { handle_token_count_event( conversation_id, turn_id.clone(), + /*analytics_events_client*/ None, TokenCountEvent { info: None, rate_limits: None, 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 a658811eb4..d878e33cf5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -424,6 +424,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From ad9963017722baf2bee319faac24c78c8a745d36 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:05:04 -0700 Subject: [PATCH 182/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 460 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1336 insertions(+), 45 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..1fe77760ee 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,277 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..fdc45cb9b3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..d3193c6fd1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,9 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; +pub use facts::TurnSubmissionType; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f5c60fe9dc..b656df0207 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2886,6 +2903,7 @@ 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; @@ -3007,6 +3025,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3021,6 +3040,7 @@ 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(), @@ -3329,6 +3349,13 @@ 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(), }; @@ -3737,6 +3764,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3785,6 +3813,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3832,6 +3861,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4098,6 +4128,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4119,6 +4150,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4130,6 +4162,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9282720828..8a05de6f54 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 70c388a19d..a658811eb4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -352,6 +358,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dce76bf3d2..283a165aa3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -428,6 +430,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -482,6 +485,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -685,6 +689,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1538,6 +1543,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1946,11 +1952,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2268,6 +2276,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6046,6 +6059,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6332,6 +6347,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index aef0a92d76..9aea3e5594 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 29fe9df89c..a5edfba863 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2629,6 +2629,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 15303a58ff..a4336bd7d0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1140,6 +1140,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1f199b149b..f2e90196fc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From e5c3ab9f1c0080482851d8323c65b1f928db4260 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:07:21 -0700 Subject: [PATCH 183/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 3 +- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 5 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/prompt_caching.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 6 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 25 ++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 58 files changed, 567 insertions(+), 7 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..d026aeaac8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5039a0bfe7..323311e1bc 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2515,6 +2515,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3283,6 +3290,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 da21699875..deb9619f3a 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 @@ -12202,6 +12202,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14763,6 +14770,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a66c34e14d..7195b658ac 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 @@ -10057,6 +10057,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12618,6 +12625,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e0d22bfa24..ac3f15c59c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8420,6 +8426,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8a05de6f54..30cd3a057e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6647,9 +6647,10 @@ impl CodexMessageProcessor { .submit_core_op( &request_id, thread.as_ref(), - Op::UserInput { + Op::UserInputWithMetadata { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..fbab5a7726 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -615,6 +615,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..d0fe9bbeb9 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -173,6 +173,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 d878e33cf5..5358a2752f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -419,7 +421,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -496,6 +500,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1653,6 +1758,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1686,6 +1792,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 283a165aa3..196e1412fb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -119,6 +120,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -885,6 +887,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1003,6 +1006,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1088,6 +1092,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1265,6 +1276,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1433,6 +1445,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1497,6 +1510,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2512,6 +2526,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2522,6 +2537,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2587,6 +2603,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2699,6 +2716,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4633,7 +4651,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } | Op::UserInputWithMetadata { .. } | Op::UserTurn { .. } => { handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } @@ -4891,6 +4909,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4917,9 +4936,22 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } + Op::UserInputWithMetadata { + items, + final_output_json_schema, + submission_type, + } => ( + items, + SessionSettingsUpdate { + final_output_json_schema: Some(final_output_json_schema), + submission_type, + ..Default::default() + }, + ), Op::UserInput { items, final_output_json_schema, @@ -5733,6 +5765,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6371,7 +6404,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5edfba863..aa967436b6 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2811,6 +2811,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3179,6 +3180,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3653,6 +3655,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a4336bd7d0..713d8f7943 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1687,6 +1687,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1801,6 +1802,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..42ae909c85 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -191,6 +191,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..481b90e054 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1668,6 +1668,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1760,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1786,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1898,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1948,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -3149,6 +3154,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..d5c748454d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -530,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..4284cdc099 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -366,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..b62d793f58 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -712,6 +712,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +826,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +848,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +954,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +976,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index d99c3865c7..3dcd754bcf 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -405,6 +405,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7470717a04..66cf9a427c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -179,6 +179,7 @@ async fn submit_unified_exec_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -259,6 +260,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1747,6 +1749,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1840,6 +1843,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2312,6 +2316,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2421,6 +2426,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index b213e305e4..36f5d28541 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8c849ca806..9baba93215 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -723,6 +723,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f2e90196fc..42c5ad79eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -246,6 +253,19 @@ pub enum Op { final_output_json_schema: Option, }, + /// Same transport shape as [`Op::UserInput`], but preserves submission + /// metadata for analytics. + UserInputWithMetadata { + /// User input items, see `InputItem` + items: Vec, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -301,6 +321,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -581,6 +605,7 @@ impl Op { Self::RealtimeConversationText(_) => "realtime_conversation_text", Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", + Self::UserInputWithMetadata { .. } => "user_input", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dad10fa766..13bacdff82 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2235,6 +2235,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2317,6 +2318,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 03072632bc..13ec3a393d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -427,6 +428,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -445,6 +447,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 86e11b49c4..9c511e398f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5559,6 +5560,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5774,6 +5783,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7235,8 +7245,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 0c8616c662b49b3d391ce08ab4d17e3b03f3159a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:07:21 -0700 Subject: [PATCH 184/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..ffa1aa489c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + created_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + created_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..23e6cccbe4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..6c11b06c28 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 196e1412fb..0b73177e46 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -50,9 +50,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4107,47 +4137,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + created_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + created_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4812,6 +4958,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4972,11 +5119,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4987,10 +5137,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 58947f356b9ac8df5165abe8fcac89c580601dcc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:07:21 -0700 Subject: [PATCH 185/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ffa1aa489c..d087518ab9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -73,6 +73,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -234,6 +235,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -252,8 +256,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -535,8 +539,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1073,8 +1077,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1111,51 +1121,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1464,9 +1520,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1664,6 +1741,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1689,12 +1833,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 23e6cccbe4..e1c854f9ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 6c11b06c28..9b719cb3d4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 30cd3a057e..cc6c355ad7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9240,6 +9240,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 5358a2752f..3e9e3fafaf 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -415,13 +415,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0b73177e46..30af13e9c8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -695,6 +696,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1129,6 +1131,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1194,6 +1204,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1218,6 +1229,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1594,6 +1606,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6539,6 +6553,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6560,6 +6578,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aa967436b6..d37e7108d4 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1877,6 +1878,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2331,6 +2334,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2700,6 +2705,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3544,6 +3550,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 764e4d0941ce276f0c520933892e27fd39fcd088 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 186/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 402 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 +++- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++-- codex-rs/core/src/codex.rs | 186 +++++++- 8 files changed, 781 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..aa2f39fa27 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -14,11 +15,13 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +33,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +249,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1090,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1136,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1158,280 @@ fn turn_event_serializes_expected_shape() { ); } +#[test] +fn turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1_716_000_123, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_123)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[test] +fn rejected_turn_steer_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + sample_app_server_client_metadata(), + sample_runtime_metadata(), + &tracking, + CodexTurnSteerEvent { + expected_turn_id: Some("turn-expected".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::ExpectedTurnMismatch), + started_at: 1_716_000_124, + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn steer event"); + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-3")); + assert_eq!( + payload["event_params"]["expected_turn_id"], + json!("turn-expected") + ); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("expected_turn_mismatch") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_124)); + assert!(payload["event_params"].get("created_at").is_none()); +} + +#[tokio::test] +async fn turn_steer_uses_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1469,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1483,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..b03cf74619 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.started_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..34d158d8bd 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub started_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 769f27db57..77eb802ad9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -50,9 +50,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4108,47 +4138,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let started_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + started_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4813,6 +4959,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4963,11 +5110,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4978,10 +5128,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 4bd3129e2141cce906ce03e5d5f2bbd3359478c9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 187/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index aa2f39fa27..a63564c6c7 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,7 +8,6 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -30,6 +29,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -73,6 +73,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -234,6 +235,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -252,8 +256,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -535,8 +539,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1073,8 +1077,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1111,51 +1121,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[test] @@ -1464,9 +1520,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1664,6 +1741,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1689,12 +1833,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index b03cf74619..6df74c26ca 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 34d158d8bd..86fafc8c37 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 759cc60017..4c31ebdfb8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9240,6 +9240,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 5358a2752f..3e9e3fafaf 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -415,13 +415,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 77eb802ad9..ce08fad223 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -695,6 +696,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1129,6 +1131,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1194,6 +1204,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1218,6 +1229,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1594,6 +1606,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6530,6 +6544,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6551,6 +6569,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f58dea067b..8635590b5c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1879,6 +1880,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1982,6 +1984,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2333,6 +2336,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2597,6 +2601,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2702,6 +2707,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3547,6 +3553,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From e7221a47adf2c7a3462aed3e3081bb24b1df5163 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 188/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 1 + .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex.rs | 31 ++++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/guardian/review_session.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 34 ++++++ .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 16 +++ codex-rs/core/tests/suite/compact.rs | 36 ++++++ codex-rs/core/tests/suite/compact_remote.rs | 43 +++++++ .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/fork_thread.rs | 1 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 14 +++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 8 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/otel.rs | 22 ++++ codex-rs/core/tests/suite/pending_input.rs | 3 + .../core/tests/suite/permissions_messages.rs | 15 +++ codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 15 +++ codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_compression.rs | 2 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/resume.rs | 7 ++ codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/search_tool.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 6 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/protocol/src/protocol.rs | 39 +++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 82 files changed, 792 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..d026aeaac8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5039a0bfe7..323311e1bc 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2515,6 +2515,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3283,6 +3290,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 da21699875..deb9619f3a 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 @@ -12202,6 +12202,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14763,6 +14770,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a66c34e14d..7195b658ac 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 @@ -10057,6 +10057,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12618,6 +12625,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e0d22bfa24..ac3f15c59c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8420,6 +8426,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8a05de6f54..759cc60017 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6650,6 +6650,7 @@ impl CodexMessageProcessor { Op::UserInput { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..fbab5a7726 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -615,6 +615,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..d0fe9bbeb9 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -173,6 +173,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 d878e33cf5..5358a2752f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -419,7 +421,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -496,6 +500,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1653,6 +1758,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1686,6 +1792,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 54693228fa..17e620e6ba 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -425,6 +425,7 @@ async fn send_input_submits_user_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -571,6 +572,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -678,6 +680,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 283a165aa3..769f27db57 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -119,6 +120,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -885,6 +887,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1003,6 +1006,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1088,6 +1092,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1265,6 +1276,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1433,6 +1445,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1497,6 +1510,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2209,6 +2223,7 @@ impl Session { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ) .await; @@ -2512,6 +2527,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2522,6 +2538,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2587,6 +2604,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2699,6 +2717,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4891,6 +4910,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4917,16 +4937,19 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } Op::UserInput { items, final_output_json_schema, + submission_type, } => ( items, SessionSettingsUpdate { final_output_json_schema: Some(final_output_json_schema), + submission_type, ..Default::default() }, ), @@ -5733,6 +5756,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6371,7 +6395,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9aea3e5594..82b78e9030 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -186,6 +186,7 @@ pub(crate) async fn run_codex_thread_one_shot( io.submit(Op::UserInput { items: input, final_output_json_schema, + submission_type: None, }) .await?; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5edfba863..f58dea067b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1197,6 +1197,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1252,6 +1253,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2811,6 +2813,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3149,6 +3152,7 @@ fn op_kind_distinguishes_turn_ops() { Op::UserInput { items: vec![], final_output_json_schema: None, + submission_type: None, } .kind(), "user_input" @@ -3179,6 +3183,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3653,6 +3658,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 5f09c248a4..517bdf2a53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2004,6 +2004,7 @@ async fn send_input_accepts_structured_items() { }, ], final_output_json_schema: None, + submission_type: None, }; let captured = manager .captured_ops() diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index af3c70b74e..797c3cf37b 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -51,6 +51,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -105,6 +106,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -203,6 +206,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -221,6 +225,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a4336bd7d0..13517bcc9a 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -387,6 +387,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -749,6 +750,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -947,6 +949,7 @@ async fn includes_base_instructions_override_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -993,6 +996,7 @@ async fn omits_explicit_null_base_instructions_from_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1041,6 +1045,7 @@ async fn chatgpt_auth_sends_correct_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1154,6 +1159,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1190,6 +1196,7 @@ async fn includes_user_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1335,6 +1343,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1391,6 +1400,7 @@ async fn omits_apps_guidance_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1430,6 +1440,7 @@ async fn omits_environment_context_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1484,6 +1495,7 @@ async fn skills_append_to_developer_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1535,6 +1547,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1577,6 +1590,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1617,6 +1631,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1687,6 +1702,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1729,6 +1745,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1801,6 +1818,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1844,6 +1862,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1900,6 +1919,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1936,6 +1956,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1981,6 +2002,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2025,6 +2047,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2074,6 +2097,7 @@ async fn includes_developer_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2364,6 +2388,7 @@ async fn token_count_includes_rate_limits_snapshot() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2529,6 +2554,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -2603,6 +2629,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2615,6 +2642,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2697,6 +2725,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2804,6 +2833,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2889,6 +2919,7 @@ async fn env_var_overrides_loaded_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2950,6 +2981,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2963,6 +2995,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2976,6 +3009,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 7ff4a9f88d..4ca6b04e6b 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1003,6 +1003,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1088,6 +1089,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting invalid request events"); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..f6d82bd7ca 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -84,6 +84,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -143,6 +144,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -191,6 +193,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +244,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +311,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -364,6 +369,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -391,6 +397,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -474,6 +482,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -532,6 +541,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -562,6 +572,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -621,6 +632,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -651,6 +663,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -714,6 +727,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -727,6 +741,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -783,6 +798,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..ebdd6babd1 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -245,6 +245,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -267,6 +268,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -443,6 +445,7 @@ async fn manual_compact_uses_custom_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -587,6 +590,7 @@ async fn manual_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -750,6 +754,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1262,6 +1268,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1443,6 +1451,7 @@ async fn auto_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1521,6 +1530,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1533,6 +1543,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1545,6 +1556,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1668,6 +1680,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1772,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1798,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1910,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1960,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -2037,6 +2054,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2049,6 +2067,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2061,6 +2080,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2147,6 +2167,7 @@ async fn manual_compact_retries_after_context_window_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2258,6 +2279,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -2350,6 +2372,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2365,6 +2388,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2380,6 +2404,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2541,6 +2566,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2643,6 +2669,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2839,6 +2866,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2956,6 +2984,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -3015,6 +3044,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3050,6 +3080,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }, ], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3149,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3206,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -3260,6 +3293,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user"); @@ -3272,6 +3306,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit second user"); @@ -3342,6 +3377,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit follow-up user input"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index dd99e0f391..dbea43e643 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -235,6 +235,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -249,6 +250,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -379,6 +381,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -453,6 +456,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -464,6 +468,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -579,6 +584,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -590,6 +596,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -607,6 +614,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -704,6 +712,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -715,6 +724,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -806,6 +816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -820,6 +831,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -905,6 +917,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -919,6 +932,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -987,6 +1001,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1065,6 +1080,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1147,6 +1163,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1288,6 +1305,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1303,6 +1321,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1325,6 +1344,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1419,6 +1439,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1433,6 +1454,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1502,6 +1524,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1513,6 +1536,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1577,6 +1601,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1635,6 +1660,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1648,6 +1674,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1720,6 +1747,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1734,6 +1762,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1814,6 +1843,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1827,6 +1857,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1915,6 +1946,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1942,6 +1974,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2035,6 +2068,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2119,6 +2153,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2145,6 +2180,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2261,6 +2297,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2272,6 +2309,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -2354,6 +2392,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2429,6 +2468,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2512,6 +2552,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2526,6 +2567,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2605,6 +2647,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 8f4f2ae755..4cfa7c1cb0 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -808,6 +808,7 @@ async fn user_turn(conversation: &Arc, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index e24cb74d7c..0b1c645be1 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -53,6 +53,7 @@ async fn fork_thread_twice_drops_to_first_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c6e8a48d27..07479cd9b7 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -904,6 +904,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -920,6 +921,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..3b2bc50644 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -86,6 +86,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { .submit(Op::UserInput { items: vec![expected_input.clone()], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -142,6 +143,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -200,6 +202,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -259,6 +262,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -385,6 +390,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -438,6 +444,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -530,6 +537,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +615,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +725,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +913,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1028,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1085,6 +1097,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -1144,6 +1157,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..02e0d81dd6 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -322,6 +326,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -366,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -421,6 +427,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -466,6 +473,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 96df3fd2f1..936b50ae2a 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -107,6 +107,7 @@ async fn responses_api_emits_api_request_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -150,6 +151,7 @@ async fn process_sse_emits_tracing_for_output_item() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -193,6 +195,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -237,6 +240,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -301,6 +305,7 @@ async fn process_sse_failed_event_records_response_error_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -363,6 +368,7 @@ async fn process_sse_failed_event_logs_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -412,6 +418,7 @@ async fn process_sse_failed_event_logs_missing_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -470,6 +477,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -522,6 +530,7 @@ async fn process_sse_emits_completed_telemetry() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -594,6 +603,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -678,6 +688,7 @@ async fn record_responses_sets_span_fields_for_response_events() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -762,6 +773,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -835,6 +847,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -918,6 +931,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -986,6 +1000,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1094,6 +1109,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1145,6 +1161,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1211,6 +1228,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1277,6 +1295,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1343,6 +1362,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1409,6 +1429,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1476,6 +1497,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 25a08120ae..0467c27250 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -96,6 +96,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap_or_else(|err| panic!("submit user input: {err}")); @@ -240,6 +241,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -256,6 +258,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d1738db902..53960bd728 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -53,6 +53,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -90,6 +91,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -117,6 +119,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -160,6 +163,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -214,6 +219,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +247,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -297,6 +304,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -326,6 +334,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -339,6 +348,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -396,6 +406,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -425,6 +436,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -444,6 +456,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -476,6 +489,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -528,6 +542,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index cd0ad330ae..7bc2532cbe 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -213,6 +213,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -288,6 +289,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -360,6 +362,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..764f72b60b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -151,6 +151,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -162,6 +163,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -247,6 +249,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -258,6 +261,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -320,6 +324,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -412,6 +418,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -448,6 +455,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -530,6 +538,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -681,6 +690,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -712,6 +722,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +836,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +858,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +964,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +986,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 466680e6a8..24cdd5c64f 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -46,6 +46,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index b169e229a5..de0332c340 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2062,6 +2062,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fb0125e66e..2f4fe36217 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -45,6 +45,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -92,6 +93,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index a77ad60b5a..155482cd60 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -91,6 +91,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { text_elements: text_elements.clone(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -176,6 +177,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -265,6 +267,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -305,6 +308,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -320,6 +324,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -433,6 +439,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 503bc8745e..cf7ef74293 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -726,6 +726,7 @@ async fn review_history_surfaces_in_parent_session() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d41785bd4e..9e03afdc4c 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -427,6 +427,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index d99c3865c7..3dcd754bcf 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -405,6 +405,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index c5aa57ce74..1f4283dd3e 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -98,6 +98,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -117,6 +118,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 0c4d7cb49d..bfaa123fcc 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -82,6 +82,7 @@ async fn retries_on_early_close() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7470717a04..66cf9a427c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -179,6 +179,7 @@ async fn submit_unified_exec_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -259,6 +260,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1747,6 +1749,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1840,6 +1843,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2312,6 +2316,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2421,6 +2426,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index a2303c0ecf..dc383f00dd 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -62,6 +62,7 @@ mv "${tmp_path}" "${payload_path}""#, text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index b213e305e4..36f5d28541 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 76f135d205..3d9fffbef5 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -109,6 +109,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8c849ca806..9baba93215 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -723,6 +723,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 64d553bbff..8ab7af20fe 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -114,6 +114,7 @@ pub async fn run_codex_tool_session( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, trace: None, }; @@ -161,6 +162,7 @@ pub async fn run_codex_tool_session_reply( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f2e90196fc..447038ee82 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,9 @@ pub enum Op { /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Similar to [`Op::UserInput`], but contains additional context required @@ -301,6 +311,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -517,6 +531,7 @@ impl From> for Op { Op::UserInput { items: value, final_output_json_schema: None, + submission_type: None, } } } @@ -4484,6 +4499,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4501,6 +4517,7 @@ mod tests { Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, } ); @@ -4520,6 +4537,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: Some(schema.clone()), + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4535,6 +4553,27 @@ mod tests { Ok(()) } + #[test] + fn user_input_serialization_includes_submission_type_when_some() -> Result<()> { + let op = Op::UserInput { + items: Vec::new(), + final_output_json_schema: None, + submission_type: Some(SubmissionType::PromptQueued), + }; + + let json_op = serde_json::to_value(op)?; + assert_eq!( + json_op, + json!({ + "type": "user_input", + "items": [], + "submission_type": "prompt_queued", + }) + ); + + Ok(()) + } + #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dad10fa766..13bacdff82 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2235,6 +2235,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2317,6 +2318,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 03072632bc..13ec3a393d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -427,6 +428,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -445,6 +447,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 86e11b49c4..9c511e398f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5559,6 +5560,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5774,6 +5783,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7235,8 +7245,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 89313c6fafeb3a5f84ee3870f84201c6b24ca420 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 189/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 292 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 +++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 ++++- .../app-server/tests/suite/v2/turn_steer.rs | 77 +++-- codex-rs/core/src/codex.rs | 186 +++++++++-- 8 files changed, 671 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..d68167e55c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -19,6 +19,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +31,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +247,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1088,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1134,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1156,172 @@ fn turn_event_serializes_expected_shape() { ); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_125)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_126)); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1359,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1373,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..b03cf74619 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.started_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..34d158d8bd 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub started_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 769f27db57..77eb802ad9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -50,9 +50,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4108,47 +4138,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let started_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + started_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4813,6 +4959,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4963,11 +5110,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4978,10 +5128,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 53b11e2abc50cbefb08ae73bf86a3440860a337b Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 190/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d68167e55c..639d5ba96a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,6 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -28,6 +27,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -71,6 +71,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -232,6 +233,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -250,8 +254,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -533,8 +537,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1071,8 +1075,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1109,51 +1119,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[tokio::test] @@ -1354,9 +1410,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1554,6 +1631,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1579,12 +1723,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index b03cf74619..6df74c26ca 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 34d158d8bd..86fafc8c37 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 759cc60017..4c31ebdfb8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9240,6 +9240,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 5358a2752f..3e9e3fafaf 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -415,13 +415,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 77eb802ad9..ce08fad223 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -695,6 +696,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1129,6 +1131,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1194,6 +1204,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1218,6 +1229,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1594,6 +1606,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6530,6 +6544,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6551,6 +6569,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f58dea067b..8635590b5c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1879,6 +1880,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1982,6 +1984,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2333,6 +2336,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2597,6 +2601,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2702,6 +2707,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3547,6 +3553,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From c6ac258a132aec9c746be7fbb1ebd4539da1bea1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:05:04 -0700 Subject: [PATCH 191/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1251,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1233,6 +1283,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1264,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1281,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 1947fd506d..48914d9de9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fdc45cb9b3..ed5d96bb25 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSubmissionType { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d3193c6fd1..5a4630ff47 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSubmissionType; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 a658811eb4..d878e33cf5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -424,6 +424,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From cf986a9bc583e8a04b4537b31c42eb78f83b1959 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 192/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 90 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 1 + .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex.rs | 31 ++++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/guardian/review_session.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 34 ++++++ .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 16 +++ codex-rs/core/tests/suite/compact.rs | 36 ++++++ codex-rs/core/tests/suite/compact_remote.rs | 43 +++++++ .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/fork_thread.rs | 1 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 14 +++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 8 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/otel.rs | 22 ++++ codex-rs/core/tests/suite/pending_input.rs | 3 + .../core/tests/suite/permissions_messages.rs | 15 +++ codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 15 +++ codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_compression.rs | 2 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/resume.rs | 7 ++ codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/search_tool.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 6 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/protocol/src/protocol.rs | 39 +++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 82 files changed, 792 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..d026aeaac8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -226,7 +227,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1049,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(TurnSubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1095,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1183,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(TurnSubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5039a0bfe7..323311e1bc 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2515,6 +2515,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3283,6 +3290,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 da21699875..deb9619f3a 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 @@ -12202,6 +12202,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14763,6 +14770,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a66c34e14d..7195b658ac 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 @@ -10057,6 +10057,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12618,6 +12625,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..982cf96634 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "prompt", + "prompt_queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..7c32c07052 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "prompt" | "prompt_queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e0d22bfa24..ac3f15c59c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8420,6 +8426,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8a05de6f54..759cc60017 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6650,6 +6650,7 @@ impl CodexMessageProcessor { Op::UserInput { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..fbab5a7726 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -615,6 +615,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..d0fe9bbeb9 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -173,6 +173,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 d878e33cf5..5358a2752f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -419,7 +421,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -496,6 +500,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::PromptQueued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1653,6 +1758,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1686,6 +1792,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 54693228fa..17e620e6ba 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -425,6 +425,7 @@ async fn send_input_submits_user_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -571,6 +572,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -678,6 +680,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 283a165aa3..769f27db57 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -119,6 +120,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -885,6 +887,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1003,6 +1006,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1088,6 +1092,13 @@ impl TurnContext { } } +fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::PromptQueued => TurnSubmissionType::Queued, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1265,6 +1276,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1433,6 +1445,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1497,6 +1510,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2209,6 +2223,7 @@ impl Session { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ) .await; @@ -2512,6 +2527,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2522,6 +2538,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2587,6 +2604,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2699,6 +2717,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4891,6 +4910,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4917,16 +4937,19 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } Op::UserInput { items, final_output_json_schema, + submission_type, } => ( items, SessionSettingsUpdate { final_output_json_schema: Some(final_output_json_schema), + submission_type, ..Default::default() }, ), @@ -5733,6 +5756,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6371,7 +6395,12 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9aea3e5594..82b78e9030 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -186,6 +186,7 @@ pub(crate) async fn run_codex_thread_one_shot( io.submit(Op::UserInput { items: input, final_output_json_schema, + submission_type: None, }) .await?; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5edfba863..f58dea067b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1197,6 +1197,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1252,6 +1253,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2811,6 +2813,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3149,6 +3152,7 @@ fn op_kind_distinguishes_turn_ops() { Op::UserInput { items: vec![], final_output_json_schema: None, + submission_type: None, } .kind(), "user_input" @@ -3179,6 +3183,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3653,6 +3658,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 5f09c248a4..517bdf2a53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2004,6 +2004,7 @@ async fn send_input_accepts_structured_items() { }, ], final_output_json_schema: None, + submission_type: None, }; let captured = manager .captured_ops() diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index af3c70b74e..797c3cf37b 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -51,6 +51,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -105,6 +106,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -203,6 +206,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -221,6 +225,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a4336bd7d0..13517bcc9a 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -387,6 +387,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -749,6 +750,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -947,6 +949,7 @@ async fn includes_base_instructions_override_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -993,6 +996,7 @@ async fn omits_explicit_null_base_instructions_from_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1041,6 +1045,7 @@ async fn chatgpt_auth_sends_correct_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1154,6 +1159,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1190,6 +1196,7 @@ async fn includes_user_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1335,6 +1343,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1391,6 +1400,7 @@ async fn omits_apps_guidance_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1430,6 +1440,7 @@ async fn omits_environment_context_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1484,6 +1495,7 @@ async fn skills_append_to_developer_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1535,6 +1547,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1577,6 +1590,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1617,6 +1631,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1687,6 +1702,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1729,6 +1745,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1801,6 +1818,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1844,6 +1862,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1900,6 +1919,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1936,6 +1956,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1981,6 +2002,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2025,6 +2047,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2074,6 +2097,7 @@ async fn includes_developer_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2364,6 +2388,7 @@ async fn token_count_includes_rate_limits_snapshot() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2529,6 +2554,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -2603,6 +2629,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2615,6 +2642,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2697,6 +2725,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2804,6 +2833,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2889,6 +2919,7 @@ async fn env_var_overrides_loaded_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2950,6 +2981,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2963,6 +2995,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2976,6 +3009,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 7ff4a9f88d..4ca6b04e6b 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1003,6 +1003,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1088,6 +1089,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting invalid request events"); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..f6d82bd7ca 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -84,6 +84,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -143,6 +144,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -191,6 +193,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +244,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +311,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -364,6 +369,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -391,6 +397,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -474,6 +482,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -532,6 +541,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -562,6 +572,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -621,6 +632,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -651,6 +663,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -714,6 +727,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -727,6 +741,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -783,6 +798,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..ebdd6babd1 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -245,6 +245,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -267,6 +268,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -443,6 +445,7 @@ async fn manual_compact_uses_custom_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -587,6 +590,7 @@ async fn manual_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -750,6 +754,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1262,6 +1268,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1443,6 +1451,7 @@ async fn auto_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1521,6 +1530,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1533,6 +1543,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1545,6 +1556,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1668,6 +1680,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1772,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1798,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1910,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1960,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -2037,6 +2054,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2049,6 +2067,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2061,6 +2080,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2147,6 +2167,7 @@ async fn manual_compact_retries_after_context_window_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2258,6 +2279,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -2350,6 +2372,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2365,6 +2388,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2380,6 +2404,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2541,6 +2566,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2643,6 +2669,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2839,6 +2866,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2956,6 +2984,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -3015,6 +3044,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3050,6 +3080,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }, ], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3149,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3206,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -3260,6 +3293,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user"); @@ -3272,6 +3306,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit second user"); @@ -3342,6 +3377,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit follow-up user input"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index dd99e0f391..dbea43e643 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -235,6 +235,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -249,6 +250,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -379,6 +381,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -453,6 +456,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -464,6 +468,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -579,6 +584,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -590,6 +596,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -607,6 +614,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -704,6 +712,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -715,6 +724,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -806,6 +816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -820,6 +831,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -905,6 +917,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -919,6 +932,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -987,6 +1001,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1065,6 +1080,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1147,6 +1163,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1288,6 +1305,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1303,6 +1321,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1325,6 +1344,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1419,6 +1439,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1433,6 +1454,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1502,6 +1524,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1513,6 +1536,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1577,6 +1601,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1635,6 +1660,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1648,6 +1674,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1720,6 +1747,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1734,6 +1762,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1814,6 +1843,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1827,6 +1857,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1915,6 +1946,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1942,6 +1974,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2035,6 +2068,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2119,6 +2153,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2145,6 +2180,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2261,6 +2297,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2272,6 +2309,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -2354,6 +2392,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2429,6 +2468,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2512,6 +2552,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2526,6 +2567,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2605,6 +2647,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 8f4f2ae755..4cfa7c1cb0 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -808,6 +808,7 @@ async fn user_turn(conversation: &Arc, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index e24cb74d7c..0b1c645be1 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -53,6 +53,7 @@ async fn fork_thread_twice_drops_to_first_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c6e8a48d27..07479cd9b7 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -904,6 +904,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -920,6 +921,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..3b2bc50644 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -86,6 +86,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { .submit(Op::UserInput { items: vec![expected_input.clone()], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -142,6 +143,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -200,6 +202,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -259,6 +262,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -385,6 +390,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -438,6 +444,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -530,6 +537,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +615,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +725,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +913,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1028,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1085,6 +1097,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -1144,6 +1157,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..02e0d81dd6 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -322,6 +326,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -366,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -421,6 +427,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -466,6 +473,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 96df3fd2f1..936b50ae2a 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -107,6 +107,7 @@ async fn responses_api_emits_api_request_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -150,6 +151,7 @@ async fn process_sse_emits_tracing_for_output_item() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -193,6 +195,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -237,6 +240,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -301,6 +305,7 @@ async fn process_sse_failed_event_records_response_error_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -363,6 +368,7 @@ async fn process_sse_failed_event_logs_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -412,6 +418,7 @@ async fn process_sse_failed_event_logs_missing_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -470,6 +477,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -522,6 +530,7 @@ async fn process_sse_emits_completed_telemetry() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -594,6 +603,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -678,6 +688,7 @@ async fn record_responses_sets_span_fields_for_response_events() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -762,6 +773,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -835,6 +847,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -918,6 +931,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -986,6 +1000,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1094,6 +1109,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1145,6 +1161,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1211,6 +1228,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1277,6 +1295,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1343,6 +1362,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1409,6 +1429,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1476,6 +1497,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 25a08120ae..0467c27250 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -96,6 +96,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap_or_else(|err| panic!("submit user input: {err}")); @@ -240,6 +241,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -256,6 +258,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d1738db902..53960bd728 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -53,6 +53,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -90,6 +91,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -117,6 +119,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -160,6 +163,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -214,6 +219,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +247,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -297,6 +304,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -326,6 +334,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -339,6 +348,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -396,6 +406,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -425,6 +436,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -444,6 +456,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -476,6 +489,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -528,6 +542,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index cd0ad330ae..7bc2532cbe 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -213,6 +213,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -288,6 +289,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -360,6 +362,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..764f72b60b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -151,6 +151,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -162,6 +163,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -247,6 +249,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -258,6 +261,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -320,6 +324,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -412,6 +418,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -448,6 +455,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -530,6 +538,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -681,6 +690,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -712,6 +722,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +836,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +858,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +964,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +986,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 466680e6a8..24cdd5c64f 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -46,6 +46,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index b169e229a5..de0332c340 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2062,6 +2062,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fb0125e66e..2f4fe36217 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -45,6 +45,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -92,6 +93,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index a77ad60b5a..155482cd60 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -91,6 +91,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { text_elements: text_elements.clone(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -176,6 +177,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -265,6 +267,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -305,6 +308,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -320,6 +324,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -433,6 +439,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 503bc8745e..cf7ef74293 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -726,6 +726,7 @@ async fn review_history_surfaces_in_parent_session() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d41785bd4e..9e03afdc4c 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -427,6 +427,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index d99c3865c7..3dcd754bcf 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -405,6 +405,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index c5aa57ce74..1f4283dd3e 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -98,6 +98,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -117,6 +118,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 0c4d7cb49d..bfaa123fcc 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -82,6 +82,7 @@ async fn retries_on_early_close() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7470717a04..66cf9a427c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -179,6 +179,7 @@ async fn submit_unified_exec_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -259,6 +260,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1747,6 +1749,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1840,6 +1843,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2312,6 +2316,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2421,6 +2426,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index a2303c0ecf..dc383f00dd 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -62,6 +62,7 @@ mv "${tmp_path}" "${payload_path}""#, text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index b213e305e4..36f5d28541 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 76f135d205..3d9fffbef5 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -109,6 +109,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8c849ca806..9baba93215 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -723,6 +723,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 64d553bbff..8ab7af20fe 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -114,6 +114,7 @@ pub async fn run_codex_tool_session( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, trace: None, }; @@ -161,6 +162,7 @@ pub async fn run_codex_tool_session_reply( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f2e90196fc..447038ee82 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Prompt, + PromptQueued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,9 @@ pub enum Op { /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Similar to [`Op::UserInput`], but contains additional context required @@ -301,6 +311,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -517,6 +531,7 @@ impl From> for Op { Op::UserInput { items: value, final_output_json_schema: None, + submission_type: None, } } } @@ -4484,6 +4499,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4501,6 +4517,7 @@ mod tests { Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, } ); @@ -4520,6 +4537,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: Some(schema.clone()), + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4535,6 +4553,27 @@ mod tests { Ok(()) } + #[test] + fn user_input_serialization_includes_submission_type_when_some() -> Result<()> { + let op = Op::UserInput { + items: Vec::new(), + final_output_json_schema: None, + submission_type: Some(SubmissionType::PromptQueued), + }; + + let json_op = serde_json::to_value(op)?; + assert_eq!( + json_op, + json!({ + "type": "user_input", + "items": [], + "submission_type": "prompt_queued", + }) + ); + + Ok(()) + } + #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dad10fa766..13bacdff82 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2235,6 +2235,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2317,6 +2318,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 03072632bc..13ec3a393d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -427,6 +428,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -445,6 +447,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 86e11b49c4..9c511e398f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5559,6 +5560,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5774,6 +5783,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7235,8 +7245,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::PromptQueued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..f272ceb81e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::PromptQueued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 7973184a406a75f096d72efa39cc6f2437cfc51e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 193/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 292 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 +++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 ++++- .../app-server/tests/suite/v2/turn_steer.rs | 77 +++-- codex-rs/core/src/codex.rs | 186 +++++++++-- 8 files changed, 671 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d026aeaac8..d68167e55c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -19,6 +19,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +31,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -243,6 +247,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1065,7 +1088,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1111,7 +1134,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1133,6 +1156,172 @@ fn turn_event_serializes_expected_shape() { ); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_125)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_126)); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1170,6 +1359,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1183,6 +1373,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 48914d9de9..b03cf74619 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.started_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ed5d96bb25..34d158d8bd 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub started_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5a4630ff47..b3e829ea03 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnSubmissionType; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 769f27db57..77eb802ad9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -50,9 +50,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; @@ -240,6 +244,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4108,47 +4138,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let started_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + started_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4813,6 +4959,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4963,11 +5110,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4978,10 +5128,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From 979a31754125f3f52e652f9c91d97b02495be2ae Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 14:49:00 -0700 Subject: [PATCH 194/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d68167e55c..639d5ba96a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,6 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -28,6 +27,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -71,6 +71,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -232,6 +233,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -250,8 +254,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -533,8 +537,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1071,8 +1075,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(TurnSubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1109,51 +1119,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[tokio::test] @@ -1354,9 +1410,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1554,6 +1631,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1579,12 +1723,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index b03cf74619..6df74c26ca 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -73,8 +66,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -134,8 +127,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -330,8 +329,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 34d158d8bd..86fafc8c37 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub enum TurnSubmissionType { Queued, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index b3e829ea03..8fa4da8228 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 759cc60017..4c31ebdfb8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9240,6 +9240,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 5358a2752f..3e9e3fafaf 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -415,13 +415,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 77eb802ad9..ce08fad223 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -695,6 +696,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1129,6 +1131,14 @@ fn turn_submission_type(submission_type: SubmissionType) -> TurnSubmissionType { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1194,6 +1204,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1218,6 +1229,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1594,6 +1606,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6530,6 +6544,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6551,6 +6569,9 @@ async fn track_turn_resolved_config_analytics( .map(turn_submission_type) .unwrap_or(TurnSubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f58dea067b..8635590b5c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1879,6 +1880,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1982,6 +1984,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2333,6 +2336,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2597,6 +2601,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2702,6 +2707,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3547,6 +3553,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From c4a4cb7092388aa321cd3f97d2b61c24b666bf49 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 15:32:31 -0700 Subject: [PATCH 195/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 460 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 44 ++ codex-rs/analytics/src/facts.rs | 37 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1327 insertions(+), 45 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..1fe77760ee 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,277 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..8ba7c778e3 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,8 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +39,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +125,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..238969dd76 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,34 @@ pub fn build_track_events_context( } } +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +125,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..3acf8198a1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,8 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f5c60fe9dc..b656df0207 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2886,6 +2903,7 @@ 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; @@ -3007,6 +3025,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3021,6 +3040,7 @@ 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(), @@ -3329,6 +3349,13 @@ 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(), }; @@ -3737,6 +3764,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3785,6 +3813,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3832,6 +3861,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4098,6 +4128,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4119,6 +4150,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4130,6 +4162,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9282720828..8a05de6f54 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 70c388a19d..a658811eb4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -352,6 +358,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dce76bf3d2..283a165aa3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -428,6 +430,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -482,6 +485,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -685,6 +689,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1538,6 +1543,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1946,11 +1952,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2268,6 +2276,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6046,6 +6059,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6332,6 +6347,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index aef0a92d76..9aea3e5594 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 29fe9df89c..a5edfba863 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2629,6 +2629,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 15303a58ff..a4336bd7d0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1140,6 +1140,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1f199b149b..f2e90196fc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 00ea006d87a9d000e8059dcdf36f921824c68b98 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 15:32:31 -0700 Subject: [PATCH 196/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1251,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1233,6 +1283,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1264,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1281,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8ba7c778e3..8d2eae97b6 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -155,6 +155,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 238969dd76..32edee351a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -126,6 +134,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 3acf8198a1..bcd5216d20 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -12,6 +12,7 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 a658811eb4..d878e33cf5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -424,6 +424,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 44c2f4b7e03761e2d81b03590a1e1d63484f8475 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 15:32:31 -0700 Subject: [PATCH 197/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 89 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 1 + .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex.rs | 22 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/guardian/review_session.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 34 ++++++ .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 16 +++ codex-rs/core/tests/suite/compact.rs | 36 ++++++ codex-rs/core/tests/suite/compact_remote.rs | 43 +++++++ .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/fork_thread.rs | 1 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 14 +++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 8 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/otel.rs | 22 ++++ codex-rs/core/tests/suite/pending_input.rs | 3 + .../core/tests/suite/permissions_messages.rs | 15 +++ codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 15 +++ codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_compression.rs | 2 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/resume.rs | 7 ++ codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/search_tool.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 6 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/protocol/src/protocol.rs | 39 +++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 82 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..2940669d4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -226,7 +226,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(codex_protocol::protocol::SubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1048,7 +1048,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(codex_protocol::protocol::SubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1094,7 +1094,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1182,6 +1182,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(codex_protocol::protocol::SubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5039a0bfe7..c74fc23a95 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2515,6 +2515,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3283,6 +3290,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 da21699875..435bcbf2f5 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 @@ -12202,6 +12202,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14763,6 +14770,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a66c34e14d..1f6f2ed33a 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 @@ -10057,6 +10057,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12618,6 +12625,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..f85604df57 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..2f4eda6e7e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "default" | "queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e0d22bfa24..ac3f15c59c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8420,6 +8426,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8a05de6f54..759cc60017 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6650,6 +6650,7 @@ impl CodexMessageProcessor { Op::UserInput { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..fbab5a7726 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -615,6 +615,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..d0fe9bbeb9 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -173,6 +173,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 d878e33cf5..05c870a756 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -419,7 +421,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -496,6 +500,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::Queued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1653,6 +1758,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1686,6 +1792,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 54693228fa..17e620e6ba 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -425,6 +425,7 @@ async fn send_input_submits_user_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -571,6 +572,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -678,6 +680,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 283a165aa3..c758b07736 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -119,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -885,6 +886,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1003,6 +1005,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1265,6 +1268,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1433,6 +1437,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1497,6 +1502,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2209,6 +2215,7 @@ impl Session { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ) .await; @@ -2512,6 +2519,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2522,6 +2530,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2587,6 +2596,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2699,6 +2709,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4891,6 +4902,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4917,16 +4929,19 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } Op::UserInput { items, final_output_json_schema, + submission_type, } => ( items, SessionSettingsUpdate { final_output_json_schema: Some(final_output_json_schema), + submission_type, ..Default::default() }, ), @@ -5733,6 +5748,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6371,7 +6387,11 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .unwrap_or(SubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9aea3e5594..82b78e9030 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -186,6 +186,7 @@ pub(crate) async fn run_codex_thread_one_shot( io.submit(Op::UserInput { items: input, final_output_json_schema, + submission_type: None, }) .await?; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5edfba863..f58dea067b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1197,6 +1197,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1252,6 +1253,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2811,6 +2813,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3149,6 +3152,7 @@ fn op_kind_distinguishes_turn_ops() { Op::UserInput { items: vec![], final_output_json_schema: None, + submission_type: None, } .kind(), "user_input" @@ -3179,6 +3183,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3653,6 +3658,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 5f09c248a4..517bdf2a53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2004,6 +2004,7 @@ async fn send_input_accepts_structured_items() { }, ], final_output_json_schema: None, + submission_type: None, }; let captured = manager .captured_ops() diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index af3c70b74e..797c3cf37b 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -51,6 +51,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -105,6 +106,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -203,6 +206,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -221,6 +225,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a4336bd7d0..13517bcc9a 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -387,6 +387,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -749,6 +750,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -947,6 +949,7 @@ async fn includes_base_instructions_override_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -993,6 +996,7 @@ async fn omits_explicit_null_base_instructions_from_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1041,6 +1045,7 @@ async fn chatgpt_auth_sends_correct_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1154,6 +1159,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1190,6 +1196,7 @@ async fn includes_user_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1335,6 +1343,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1391,6 +1400,7 @@ async fn omits_apps_guidance_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1430,6 +1440,7 @@ async fn omits_environment_context_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1484,6 +1495,7 @@ async fn skills_append_to_developer_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1535,6 +1547,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1577,6 +1590,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1617,6 +1631,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1687,6 +1702,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1729,6 +1745,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1801,6 +1818,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1844,6 +1862,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1900,6 +1919,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1936,6 +1956,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1981,6 +2002,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2025,6 +2047,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2074,6 +2097,7 @@ async fn includes_developer_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2364,6 +2388,7 @@ async fn token_count_includes_rate_limits_snapshot() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2529,6 +2554,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -2603,6 +2629,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2615,6 +2642,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2697,6 +2725,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2804,6 +2833,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2889,6 +2919,7 @@ async fn env_var_overrides_loaded_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2950,6 +2981,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2963,6 +2995,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2976,6 +3009,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 7ff4a9f88d..4ca6b04e6b 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1003,6 +1003,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1088,6 +1089,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting invalid request events"); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..f6d82bd7ca 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -84,6 +84,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -143,6 +144,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -191,6 +193,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +244,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +311,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -364,6 +369,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -391,6 +397,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -474,6 +482,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -532,6 +541,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -562,6 +572,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -621,6 +632,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -651,6 +663,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -714,6 +727,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -727,6 +741,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -783,6 +798,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..ebdd6babd1 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -245,6 +245,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -267,6 +268,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -443,6 +445,7 @@ async fn manual_compact_uses_custom_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -587,6 +590,7 @@ async fn manual_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -750,6 +754,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1262,6 +1268,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1443,6 +1451,7 @@ async fn auto_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1521,6 +1530,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1533,6 +1543,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1545,6 +1556,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1668,6 +1680,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1772,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1798,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1910,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1960,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -2037,6 +2054,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2049,6 +2067,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2061,6 +2080,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2147,6 +2167,7 @@ async fn manual_compact_retries_after_context_window_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2258,6 +2279,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -2350,6 +2372,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2365,6 +2388,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2380,6 +2404,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2541,6 +2566,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2643,6 +2669,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2839,6 +2866,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2956,6 +2984,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -3015,6 +3044,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3050,6 +3080,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }, ], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3149,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3206,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -3260,6 +3293,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user"); @@ -3272,6 +3306,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit second user"); @@ -3342,6 +3377,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit follow-up user input"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index dd99e0f391..dbea43e643 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -235,6 +235,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -249,6 +250,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -379,6 +381,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -453,6 +456,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -464,6 +468,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -579,6 +584,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -590,6 +596,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -607,6 +614,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -704,6 +712,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -715,6 +724,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -806,6 +816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -820,6 +831,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -905,6 +917,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -919,6 +932,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -987,6 +1001,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1065,6 +1080,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1147,6 +1163,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1288,6 +1305,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1303,6 +1321,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1325,6 +1344,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1419,6 +1439,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1433,6 +1454,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1502,6 +1524,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1513,6 +1536,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1577,6 +1601,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1635,6 +1660,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1648,6 +1674,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1720,6 +1747,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1734,6 +1762,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1814,6 +1843,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1827,6 +1857,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1915,6 +1946,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1942,6 +1974,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2035,6 +2068,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2119,6 +2153,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2145,6 +2180,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2261,6 +2297,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2272,6 +2309,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -2354,6 +2392,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2429,6 +2468,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2512,6 +2552,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2526,6 +2567,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2605,6 +2647,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 8f4f2ae755..4cfa7c1cb0 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -808,6 +808,7 @@ async fn user_turn(conversation: &Arc, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index e24cb74d7c..0b1c645be1 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -53,6 +53,7 @@ async fn fork_thread_twice_drops_to_first_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c6e8a48d27..07479cd9b7 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -904,6 +904,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -920,6 +921,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..3b2bc50644 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -86,6 +86,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { .submit(Op::UserInput { items: vec![expected_input.clone()], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -142,6 +143,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -200,6 +202,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -259,6 +262,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -385,6 +390,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -438,6 +444,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -530,6 +537,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +615,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +725,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +913,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1028,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1085,6 +1097,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -1144,6 +1157,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..02e0d81dd6 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -322,6 +326,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -366,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -421,6 +427,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -466,6 +473,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 96df3fd2f1..936b50ae2a 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -107,6 +107,7 @@ async fn responses_api_emits_api_request_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -150,6 +151,7 @@ async fn process_sse_emits_tracing_for_output_item() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -193,6 +195,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -237,6 +240,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -301,6 +305,7 @@ async fn process_sse_failed_event_records_response_error_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -363,6 +368,7 @@ async fn process_sse_failed_event_logs_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -412,6 +418,7 @@ async fn process_sse_failed_event_logs_missing_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -470,6 +477,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -522,6 +530,7 @@ async fn process_sse_emits_completed_telemetry() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -594,6 +603,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -678,6 +688,7 @@ async fn record_responses_sets_span_fields_for_response_events() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -762,6 +773,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -835,6 +847,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -918,6 +931,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -986,6 +1000,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1094,6 +1109,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1145,6 +1161,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1211,6 +1228,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1277,6 +1295,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1343,6 +1362,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1409,6 +1429,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1476,6 +1497,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 25a08120ae..0467c27250 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -96,6 +96,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap_or_else(|err| panic!("submit user input: {err}")); @@ -240,6 +241,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -256,6 +258,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d1738db902..53960bd728 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -53,6 +53,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -90,6 +91,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -117,6 +119,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -160,6 +163,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -214,6 +219,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +247,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -297,6 +304,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -326,6 +334,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -339,6 +348,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -396,6 +406,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -425,6 +436,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -444,6 +456,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -476,6 +489,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -528,6 +542,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index cd0ad330ae..7bc2532cbe 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -213,6 +213,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -288,6 +289,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -360,6 +362,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..764f72b60b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -151,6 +151,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -162,6 +163,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -247,6 +249,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -258,6 +261,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -320,6 +324,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -412,6 +418,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -448,6 +455,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -530,6 +538,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -681,6 +690,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -712,6 +722,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +836,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +858,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +964,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +986,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 466680e6a8..24cdd5c64f 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -46,6 +46,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index b169e229a5..de0332c340 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2062,6 +2062,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fb0125e66e..2f4fe36217 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -45,6 +45,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -92,6 +93,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index a77ad60b5a..155482cd60 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -91,6 +91,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { text_elements: text_elements.clone(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -176,6 +177,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -265,6 +267,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -305,6 +308,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -320,6 +324,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -433,6 +439,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 503bc8745e..cf7ef74293 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -726,6 +726,7 @@ async fn review_history_surfaces_in_parent_session() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d41785bd4e..9e03afdc4c 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -427,6 +427,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index d99c3865c7..3dcd754bcf 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -405,6 +405,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index c5aa57ce74..1f4283dd3e 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -98,6 +98,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -117,6 +118,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 0c4d7cb49d..bfaa123fcc 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -82,6 +82,7 @@ async fn retries_on_early_close() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7470717a04..66cf9a427c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -179,6 +179,7 @@ async fn submit_unified_exec_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -259,6 +260,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1747,6 +1749,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1840,6 +1843,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2312,6 +2316,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2421,6 +2426,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index a2303c0ecf..dc383f00dd 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -62,6 +62,7 @@ mv "${tmp_path}" "${payload_path}""#, text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index b213e305e4..36f5d28541 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 76f135d205..3d9fffbef5 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -109,6 +109,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8c849ca806..9baba93215 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -723,6 +723,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 64d553bbff..8ab7af20fe 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -114,6 +114,7 @@ pub async fn run_codex_tool_session( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, trace: None, }; @@ -161,6 +162,7 @@ pub async fn run_codex_tool_session_reply( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f2e90196fc..f67f783181 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Default, + Queued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,9 @@ pub enum Op { /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Similar to [`Op::UserInput`], but contains additional context required @@ -301,6 +311,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -517,6 +531,7 @@ impl From> for Op { Op::UserInput { items: value, final_output_json_schema: None, + submission_type: None, } } } @@ -4484,6 +4499,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4501,6 +4517,7 @@ mod tests { Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, } ); @@ -4520,6 +4537,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: Some(schema.clone()), + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4535,6 +4553,27 @@ mod tests { Ok(()) } + #[test] + fn user_input_serialization_includes_submission_type_when_some() -> Result<()> { + let op = Op::UserInput { + items: Vec::new(), + final_output_json_schema: None, + submission_type: Some(SubmissionType::Queued), + }; + + let json_op = serde_json::to_value(op)?; + assert_eq!( + json_op, + json!({ + "type": "user_input", + "items": [], + "submission_type": "queued", + }) + ); + + Ok(()) + } + #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dad10fa766..13bacdff82 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2235,6 +2235,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2317,6 +2318,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 03072632bc..13ec3a393d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -427,6 +428,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -445,6 +447,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 86e11b49c4..4a1da4366c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5559,6 +5560,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5774,6 +5783,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7235,8 +7245,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::Queued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..d075d939dd 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::Queued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From b6f9c019538659e789f18618fd71a34fab5c2ed7 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 15:32:31 -0700 Subject: [PATCH 198/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 292 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 +++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 ++++- .../app-server/tests/suite/v2/turn_steer.rs | 77 +++-- codex-rs/core/src/codex.rs | 186 +++++++++-- 8 files changed, 671 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2940669d4b..e40572bdd6 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -19,6 +19,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +31,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -242,6 +246,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1064,7 +1087,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1110,7 +1133,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1132,6 +1155,172 @@ fn turn_event_serializes_expected_shape() { ); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_125)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_126)); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1169,6 +1358,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1182,6 +1372,104 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn queued_submission_type_emits_queued_turn_event() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8d2eae97b6..f89ec84c0f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; @@ -40,6 +43,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.started_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 32edee351a..9e003a09d6 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -74,6 +74,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub started_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -135,6 +162,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -142,6 +170,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index bcd5216d20..646df1c81b 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c758b07736..6c705e0249 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -50,9 +50,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -239,6 +243,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4100,47 +4130,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let started_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + started_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4805,6 +4951,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4955,11 +5102,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4970,10 +5120,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From d72810df951092b71795f547fa184d93f59df3c4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 15:32:31 -0700 Subject: [PATCH 199/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e40572bdd6..60b93d0969 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,6 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -28,6 +27,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -70,6 +70,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -231,6 +232,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: Some(codex_protocol::protocol::SubmissionType::Default), + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -249,8 +253,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -532,8 +536,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1070,8 +1074,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: Some(codex_protocol::protocol::SubmissionType::Default), + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,51 +1118,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": "default", - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!("default")); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[tokio::test] @@ -1353,9 +1409,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1553,6 +1630,73 @@ async fn queued_submission_type_emits_queued_turn_event() { assert_eq!(payload["event_params"]["submission_type"], json!("queued")); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1578,12 +1722,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index f89ec84c0f..4e501299ae 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -22,14 +23,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -72,8 +65,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -133,8 +126,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, pub(crate) submission_type: Option, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -329,8 +328,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -367,3 +366,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 9e003a09d6..e62a2d21b9 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -45,6 +46,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -66,6 +70,14 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 646df1c81b..bc5638c6d2 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 759cc60017..4c31ebdfb8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9240,6 +9240,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 05c870a756..cfade3d5e5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -415,13 +415,24 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); assert_eq!(event["event_params"]["submission_type"], "default"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6c705e0249..ccd601fc2d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -694,6 +695,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1121,6 +1123,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1186,6 +1196,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1210,6 +1221,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1586,6 +1598,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6522,6 +6536,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6542,6 +6560,9 @@ async fn track_turn_resolved_config_analytics( .submission_type .unwrap_or(SubmissionType::Default), ), + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f58dea067b..8635590b5c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1879,6 +1880,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1982,6 +1984,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2333,6 +2336,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2597,6 +2601,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2702,6 +2707,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3547,6 +3553,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 720b401145bfd45a145e8bfc3bb29062b9c6f1d1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 16:21:52 -0700 Subject: [PATCH 200/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 460 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 44 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1334 insertions(+), 45 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..1fe77760ee 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,277 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..e880bfd88b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,8 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +39,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +125,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..136c7a325f 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..3acf8198a1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,8 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f5c60fe9dc..b656df0207 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2886,6 +2903,7 @@ 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; @@ -3007,6 +3025,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3021,6 +3040,7 @@ 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(), @@ -3329,6 +3349,13 @@ 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(), }; @@ -3737,6 +3764,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3785,6 +3813,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3832,6 +3861,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4098,6 +4128,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4119,6 +4150,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4130,6 +4162,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9282720828..8a05de6f54 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 70c388a19d..a658811eb4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -352,6 +358,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dce76bf3d2..283a165aa3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -428,6 +430,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -482,6 +485,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -685,6 +689,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1538,6 +1543,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1946,11 +1952,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2268,6 +2276,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6046,6 +6059,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6332,6 +6347,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index aef0a92d76..9aea3e5594 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 29fe9df89c..a5edfba863 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2629,6 +2629,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 15303a58ff..a4336bd7d0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1140,6 +1140,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1f199b149b..f2e90196fc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 493c4127804b285c56567039c9f7f2690dd8fe2e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 16:21:56 -0700 Subject: [PATCH 201/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..af7d957ea9 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -66,6 +67,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -181,6 +183,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -232,6 +248,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -301,6 +318,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1045,6 +1073,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1086,6 +1119,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1105,6 +1143,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1133,6 +1172,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1146,6 +1193,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1175,6 +1223,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1202,6 +1251,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1233,6 +1283,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1264,6 +1315,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1281,6 +1333,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index e880bfd88b..a20e1df874 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -155,6 +155,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 136c7a325f..8b845dbdd2 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -65,6 +66,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -133,6 +141,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 3acf8198a1..bcd5216d20 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -12,6 +12,7 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 80e6786af5..e24900ef04 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -532,6 +562,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -563,6 +594,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 a658811eb4..d878e33cf5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -424,6 +424,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 51e9fb4434090eed6302cd26e770b78f0a039408 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 16:21:56 -0700 Subject: [PATCH 202/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 291 +++++++++++++++++- codex-rs/analytics/src/client.rs | 11 + codex-rs/analytics/src/events.rs | 42 +++ codex-rs/analytics/src/facts.rs | 33 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 75 ++++- .../app-server/tests/suite/v2/turn_steer.rs | 77 +++-- codex-rs/core/src/codex.rs | 186 +++++++++-- 8 files changed, 670 insertions(+), 48 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index af7d957ea9..918848d9ea 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -19,6 +19,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -30,6 +31,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -242,6 +246,25 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -1064,7 +1087,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1110,7 +1133,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1132,6 +1155,172 @@ fn turn_event_serializes_expected_shape() { ); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 2, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1_716_000_125, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_125)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: String::new(), + thread_id: "thread-2".to_string(), + turn_id: String::new(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert_eq!(payload["event_params"]["started_at"], json!(1_716_000_126)); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: None, + num_input_images: 1, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 1_716_000_126, + }, + })), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1169,6 +1358,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1182,6 +1372,103 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 0, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 1, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: None, + accepted_turn_id: None, + num_input_images: 0, + result: TurnSteerResult::Rejected, + rejection_reason: Some(TurnSteerRejectionReason::NoActiveTurn), + started_at: 2, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer(TurnSteerInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + }, + turn_steer: CodexTurnSteerEvent { + expected_turn_id: Some("turn-2".to_string()), + accepted_turn_id: Some("turn-2".to_string()), + num_input_images: 1, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at: 3, + }, + })), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..5a5d2f30b5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -6,6 +6,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -14,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnSteerInput; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; @@ -203,6 +205,15 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_steer(&self, tracking: TrackEventsContext, turn_steer: CodexTurnSteerEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnSteer( + TurnSteerInput { + tracking, + turn_steer, + }, + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index a20e1df874..92f15fb563 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; @@ -40,6 +43,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) started_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + started_at: turn_steer.started_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 8b845dbdd2..127244ea5b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -81,6 +81,33 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub started_at: u64, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -142,6 +169,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnSteer(TurnSteerInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), @@ -149,6 +177,11 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +pub(crate) struct TurnSteerInput { + pub tracking: TrackEventsContext, + pub turn_steer: CodexTurnSteerEvent, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index bcd5216d20..646df1c81b 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,12 +6,15 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e24900ef04..d6f3ebd5e5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,20 +17,25 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerInput; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -57,6 +63,7 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, } struct ConnectionState { @@ -89,6 +96,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -135,6 +143,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnSteer(input) => { + self.ingest_turn_steer(input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -198,6 +209,8 @@ impl AnalyticsReducer { let ClientRequest::TurnStart { params, .. } = request else { return; }; + self.thread_connections + .insert(params.thread_id.clone(), connection_id); self.requests.insert( (connection_id, request_id), RequestState::TurnStart(PendingTurnStartState { @@ -229,6 +242,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +264,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,6 +417,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); @@ -427,6 +443,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +462,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -478,6 +496,8 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + self.thread_connections + .insert(thread.id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -500,6 +520,59 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer(&mut self, input: TurnSteerInput, out: &mut Vec) { + let TurnSteerInput { + tracking, + turn_steer, + } = input; + if matches!(turn_steer.result, TurnSteerResult::Accepted) + && let Some(accepted_turn_id) = turn_steer.accepted_turn_id.as_ref() + && let Some(turn_state) = self.turns.get_mut(accepted_turn_id) + { + turn_state.steer_count += 1; + } + let Some((app_server_client, runtime)) = + self.connection_metadata_for_turn_steer(&tracking, &turn_steer) + else { + return; + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + app_server_client, + runtime, + &tracking, + turn_steer, + ), + })); + } + + fn connection_metadata_for_turn_steer( + &self, + tracking: &TrackEventsContext, + turn_steer: &CodexTurnSteerEvent, + ) -> Option<(CodexAppServerClientMetadata, CodexRuntimeMetadata)> { + let turn_connection_id = turn_steer + .accepted_turn_id + .as_ref() + .or(turn_steer.expected_turn_id.as_ref()) + .or(Some(&tracking.turn_id)) + .and_then(|turn_id| self.turns.get(turn_id)) + .and_then(|turn_state| turn_state.connection_id); + + let connection_id = turn_connection_id + .or_else(|| self.thread_connections.get(&tracking.thread_id).copied()); + + connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }) + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -585,7 +658,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 283a165aa3..f41b7d5913 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -50,9 +50,13 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; +use codex_analytics::TurnSteerRejectionReason; +use codex_analytics::TurnSteerResult; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -238,6 +242,32 @@ impl SteerInputError { }, } } + + fn to_turn_steer_rejection_reason(&self) -> TurnSteerRejectionReason { + match self { + Self::NoActiveTurn(_) => TurnSteerRejectionReason::NoActiveTurn, + Self::ExpectedTurnMismatch { .. } => TurnSteerRejectionReason::ExpectedTurnMismatch, + Self::ActiveTurnNotSteerable { turn_kind } => match turn_kind { + NonSteerableTurnKind::Review => TurnSteerRejectionReason::NonSteerableReview, + NonSteerableTurnKind::Compact => TurnSteerRejectionReason::NonSteerableCompact, + }, + Self::EmptyInput => TurnSteerRejectionReason::EmptyInput, + } + } +} + +struct AcceptedSteerInput { + turn_id: String, + tracking: TrackEventsContext, + expected_turn_id: String, + num_input_images: usize, +} + +struct RejectedSteerInput { + error: SteerInputError, + tracking: TrackEventsContext, + expected_turn_id: Option, + num_input_images: usize, } /// Notes from the previous real user turn. @@ -4089,47 +4119,163 @@ impl Session { input: Vec, expected_turn_id: Option<&str>, ) -> Result { - if input.is_empty() { - return Err(SteerInputError::EmptyInput); + let started_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + match self + .try_append_input_to_active_turn(input, expected_turn_id) + .await + { + Ok(accepted) => { + self.services.analytics_events_client.track_turn_steer( + accepted.tracking, + CodexTurnSteerEvent { + expected_turn_id: Some(accepted.expected_turn_id), + accepted_turn_id: Some(accepted.turn_id.clone()), + num_input_images: accepted.num_input_images, + result: TurnSteerResult::Accepted, + rejection_reason: None, + started_at, + }, + ); + Ok(accepted.turn_id) + } + Err(rejected) => { + self.services.analytics_events_client.track_turn_steer( + rejected.tracking, + CodexTurnSteerEvent { + expected_turn_id: rejected.expected_turn_id, + accepted_turn_id: None, + num_input_images: rejected.num_input_images, + result: TurnSteerResult::Rejected, + rejection_reason: Some(rejected.error.to_turn_steer_rejection_reason()), + started_at, + }, + ); + Err(rejected.error) + } } + } + + async fn try_append_input_to_active_turn( + &self, + input: Vec, + expected_turn_id: Option<&str>, + ) -> Result { + let thread_id = self.conversation_id.to_string(); + let fallback_tracking = || { + build_track_events_context( + String::new(), + thread_id.clone(), + expected_turn_id.unwrap_or_default().to_string(), + ) + }; + + if input.is_empty() { + return Err(RejectedSteerInput { + error: SteerInputError::EmptyInput, + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images: 0, + }); + } + + let num_input_images = input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count(); let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { - return Err(SteerInputError::NoActiveTurn(input)); + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; - let Some((active_turn_id, _)) = active_turn.tasks.first() else { - return Err(SteerInputError::NoActiveTurn(input)); + let Some((active_turn_id, task)) = active_turn.tasks.first() else { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, + }); }; + let active_turn_id = active_turn_id.clone(); + let tracking = build_track_events_context( + task.turn_context.model_info.slug.clone(), + thread_id.clone(), + task.turn_context.sub_id.clone(), + ); if let Some(expected_turn_id) = expected_turn_id && expected_turn_id != active_turn_id { - return Err(SteerInputError::ExpectedTurnMismatch { - expected: expected_turn_id.to_string(), - actual: active_turn_id.clone(), + return Err(RejectedSteerInput { + error: SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id, + }, + tracking, + expected_turn_id: Some(expected_turn_id.to_string()), + num_input_images, }); } match active_turn.tasks.first().map(|(_, task)| task.kind) { Some(crate::state::TaskKind::Regular) => {} Some(crate::state::TaskKind::Review) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, }); } Some(crate::state::TaskKind::Compact) => { - return Err(SteerInputError::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, + return Err(RejectedSteerInput { + error: SteerInputError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, + }, + tracking, + expected_turn_id: expected_turn_id + .map(str::to_string) + .or(Some(active_turn_id)), + num_input_images, + }); + } + None => { + return Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(input), + tracking: fallback_tracking(), + expected_turn_id: expected_turn_id.map(str::to_string), + num_input_images, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); turn_state.accept_mailbox_delivery_for_current_turn(); - Ok(active_turn_id.clone()) + drop(turn_state); + + let expected_turn_id = expected_turn_id + .map(str::to_string) + .unwrap_or_else(|| active_turn_id.clone()); + + Ok(AcceptedSteerInput { + turn_id: active_turn_id, + tracking, + expected_turn_id, + num_input_images, + }) } /// Returns the input if there was no task running to inject into. @@ -4794,6 +4940,7 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { /// Operation handlers mod handlers { + use crate::codex::RejectedSteerInput; use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; @@ -4940,11 +5087,14 @@ mod handlers { sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; match sess - .steer_input(items.clone(), /*expected_turn_id*/ None) + .try_append_input_to_active_turn(items.clone(), /*expected_turn_id*/ None) .await { Ok(_) => current_context.session_telemetry.user_prompt(&items), - Err(SteerInputError::NoActiveTurn(items)) => { + Err(RejectedSteerInput { + error: SteerInputError::NoActiveTurn(items), + .. + }) => { current_context.session_telemetry.user_prompt(&items); sess.refresh_mcp_servers_if_requested(¤t_context) .await; @@ -4955,10 +5105,10 @@ mod handlers { ) .await; } - Err(err) => { + Err(RejectedSteerInput { error, .. }) => { sess.send_event_raw(Event { id: sub_id, - msg: EventMsg::Error(err.to_error_event()), + msg: EventMsg::Error(error.to_error_event()), }) .await; } From e646117c6c89bcee0944ab45ea193a87479a6304 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 16:27:57 -0700 Subject: [PATCH 203/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 249 ++++++++++++++---- codex-rs/analytics/src/events.rs | 49 +++- codex-rs/analytics/src/facts.rs | 12 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 38 ++- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 13 +- codex-rs/core/src/codex.rs | 21 ++ codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_thread.rs | 2 + 10 files changed, 315 insertions(+), 78 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 918848d9ea..f27a4d0dc2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,6 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -28,6 +27,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -70,6 +70,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; @@ -231,6 +232,9 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { thread_id: "thread-2".to_string(), num_input_images: 1, submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -249,8 +253,8 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: None, } @@ -532,8 +536,8 @@ fn thread_initialized_event_serializes_expected_shape() { thread_id: "thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), - client_name: Some("codex-tui".to_string()), - client_version: Some("1.0.0".to_string()), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), rpc_transport: AppServerRpcTransport::Stdio, experimental_api_enabled: Some(true), }, @@ -1070,8 +1074,14 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1108,51 +1118,97 @@ fn turn_event_serializes_expected_shape() { })); let payload = serde_json::to_value(&event).expect("serialize turn event"); - + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload, + payload["event_params"]["app_server_client"], json!({ - "event_type": "codex_turn_event", - "event_params": { - "thread_id": "thread-2", - "turn_id": "turn-2", - "product_client_id": "codex-tui", - "submission_type": null, - "model": "gpt-5", - "model_provider": "openai", - "sandbox_policy": "read_only", - "reasoning_effort": "high", - "reasoning_summary": "detailed", - "service_tier": "flex", - "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", - "sandbox_network_access": true, - "collaboration_mode": "plan", - "personality": "pragmatic", - "num_input_images": 2, - "is_first_turn": true, - "status": "completed", - "turn_error": null, - "steer_count": 0, - "total_tool_call_count": null, - "shell_command_count": null, - "file_change_count": null, - "mcp_tool_call_count": null, - "dynamic_tool_call_count": null, - "subagent_tool_call_count": null, - "web_search_count": null, - "image_generation_count": null, - "input_tokens": null, - "cached_input_tokens": null, - "output_tokens": null, - "reasoning_output_tokens": null, - "total_tokens": null, - "duration_ms": 1234, - "started_at": 455, - "completed_at": 456 - } + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["submission_type"], json!(null)); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["model"], json!("gpt-5")); + assert_eq!(payload["event_params"]["model_provider"], json!("openai")); + assert_eq!( + payload["event_params"]["sandbox_policy"], + json!("read_only") + ); + assert_eq!(payload["event_params"]["reasoning_effort"], json!("high")); + assert_eq!( + payload["event_params"]["reasoning_summary"], + json!("detailed") + ); + assert_eq!(payload["event_params"]["service_tier"], json!("flex")); + assert_eq!( + payload["event_params"]["approval_policy"], + json!("on-request") + ); + assert_eq!( + payload["event_params"]["approvals_reviewer"], + json!("guardian_subagent") + ); + assert_eq!( + payload["event_params"]["sandbox_network_access"], + json!(true) + ); + assert_eq!(payload["event_params"]["collaboration_mode"], json!("plan")); + assert_eq!(payload["event_params"]["personality"], json!("pragmatic")); + assert_eq!(payload["event_params"]["num_input_images"], json!(2)); + assert_eq!(payload["event_params"]["is_first_turn"], json!(true)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!( + payload["event_params"]["total_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["shell_command_count"], json!(null)); + assert_eq!(payload["event_params"]["file_change_count"], json!(null)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(null)); + assert_eq!( + payload["event_params"]["dynamic_tool_call_count"], + json!(null) + ); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(null)); + assert_eq!( + payload["event_params"]["image_generation_count"], + json!(null) + ); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); } #[tokio::test] @@ -1353,9 +1409,30 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -1469,6 +1546,73 @@ async fn accepted_steers_increment_turn_steer_count() { let payload = serde_json::to_value(turn_event).expect("serialize turn event"); assert_eq!(payload["event_params"]["steer_count"], json!(2)); } +#[tokio::test] +async fn turn_event_includes_subagent_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.ephemeral = true; + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + resolved_config.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("worker".to_string()), + agent_role: None, + }); + resolved_config.initialization_mode = ThreadInitializationMode::Forked; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["ephemeral"], json!(true)); + assert_eq!(payload["event_params"]["thread_source"], json!("subagent")); + assert_eq!( + payload["event_params"]["initialization_mode"], + json!("forked") + ); + assert_eq!( + payload["event_params"]["subagent_source"], + json!("thread_spawn") + ); + assert_eq!( + payload["event_params"]["parent_thread_id"], + json!("11111111-1111-1111-1111-111111111111") + ); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); @@ -1494,12 +1638,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { &mut out, ) .await; - assert_eq!(out.len(), 1); - let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); - assert_eq!( - payload["event_params"]["product_client_id"], - json!(originator().value) - ); + assert!(out.is_empty()); let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 92f15fb563..29e804659e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -22,14 +23,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -72,8 +65,8 @@ pub(crate) struct SkillInvocationEventParams { #[derive(Clone, Serialize)] pub(crate) struct CodexAppServerClientMetadata { pub(crate) product_client_id: String, - pub(crate) client_name: Option, - pub(crate) client_version: Option, + pub(crate) client_name: String, + pub(crate) client_version: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) experimental_api_enabled: Option, } @@ -133,8 +126,14 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -329,8 +328,8 @@ pub(crate) fn subagent_thread_started_event_request( thread_id: input.thread_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, - client_name: Some(input.client_name), - client_version: Some(input.client_version), + client_name: input.client_name, + client_version: input.client_version, rpc_transport: AppServerRpcTransport::InProcess, experimental_api_enabled: None, }, @@ -367,3 +366,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 127244ea5b..3e67a155bb 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -14,6 +14,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -52,6 +53,9 @@ pub struct TurnResolvedConfigFact { pub thread_id: String, pub num_input_images: usize, pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, @@ -73,6 +77,14 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 646df1c81b..bc5638c6d2 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,7 @@ pub use facts::CodexTurnSteerEvent; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6f3ebd5e5..eead6f0309 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -31,6 +32,7 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -178,8 +180,8 @@ impl AnalyticsReducer { ConnectionState { app_server_client: CodexAppServerClientMetadata { product_client_id, - client_name: Some(params.client_info.name), - client_version: Some(params.client_info.version), + client_name: params.client_info.name, + client_version: params.client_info.version, rpc_transport, experimental_api_enabled: params .capabilities @@ -584,16 +586,24 @@ impl AnalyticsReducer { { return; } - let product_client_id = turn_state + let connection_metadata = turn_state .connection_id .and_then(|connection_id| self.connections.get(&connection_id)) - .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) - .unwrap_or_else(|| originator().value); + .map(|connection_state| { + ( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + ) + }); + let Some((app_server_client, runtime)) = connection_metadata else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( - product_client_id, + app_server_client, + runtime, turn_id.to_string(), turn_state, ), @@ -604,7 +614,8 @@ impl AnalyticsReducer { } fn codex_turn_event_params( - product_client_id: String, + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, ) -> CodexTurnEventParams { @@ -622,6 +633,9 @@ fn codex_turn_event_params( thread_id: _resolved_thread_id, num_input_images: _resolved_num_input_images, submission_type, + ephemeral, + session_source, + initialization_mode, model, model_provider, sandbox_policy, @@ -639,8 +653,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id, + app_server_client, + runtime, submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8a05de6f54..39b2f05280 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9239,6 +9239,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 d878e33cf5..4a56e6d1a3 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -413,12 +413,23 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f41b7d5913..043601ef73 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,7 @@ use codex_analytics::AppInvocation; use codex_analytics::CodexTurnSteerEvent; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; use codex_analytics::TrackEventsContext; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::TurnSteerRejectionReason; @@ -693,6 +694,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1118,6 +1120,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1183,6 +1193,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1207,6 +1218,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1580,6 +1592,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -6506,6 +6520,10 @@ async fn track_turn_resolved_config_analytics( return; } + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6522,6 +6540,9 @@ async fn track_turn_resolved_config_analytics( }) .count(), submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5edfba863..f530a5468d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1877,6 +1878,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1980,6 +1982,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2331,6 +2334,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2595,6 +2599,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2700,6 +2705,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3542,6 +3548,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 8e2e00945ef61465a39d50e3c2be6696e7758764 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 16:27:57 -0700 Subject: [PATCH 204/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 89 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 1 + .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex.rs | 22 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/guardian/review_session.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 34 ++++++ .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 16 +++ codex-rs/core/tests/suite/compact.rs | 36 ++++++ codex-rs/core/tests/suite/compact_remote.rs | 43 +++++++ .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/fork_thread.rs | 1 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 14 +++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 8 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/otel.rs | 22 ++++ codex-rs/core/tests/suite/pending_input.rs | 3 + .../core/tests/suite/permissions_messages.rs | 15 +++ codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 15 +++ codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_compression.rs | 2 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/resume.rs | 7 ++ codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/search_tool.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 6 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/protocol/src/protocol.rs | 39 +++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 82 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..cbec82081f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -210,7 +210,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(codex_protocol::protocol::SubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1020,7 +1020,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(codex_protocol::protocol::SubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1061,7 +1061,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1135,6 +1135,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(codex_protocol::protocol::SubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5039a0bfe7..c74fc23a95 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2515,6 +2515,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3283,6 +3290,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 da21699875..435bcbf2f5 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 @@ -12202,6 +12202,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14763,6 +14770,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a66c34e14d..1f6f2ed33a 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 @@ -10057,6 +10057,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12618,6 +12625,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..f85604df57 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..2f4eda6e7e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "default" | "queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e0d22bfa24..ac3f15c59c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8420,6 +8426,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8a05de6f54..759cc60017 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6650,6 +6650,7 @@ impl CodexMessageProcessor { Op::UserInput { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..fbab5a7726 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -615,6 +615,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..d0fe9bbeb9 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -173,6 +173,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 a658811eb4..0ffdad7d0c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -419,7 +421,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -491,6 +495,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::Queued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1648,6 +1753,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1681,6 +1787,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 54693228fa..17e620e6ba 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -425,6 +425,7 @@ async fn send_input_submits_user_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -571,6 +572,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -678,6 +680,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 283a165aa3..c758b07736 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -119,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -885,6 +886,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1003,6 +1005,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1265,6 +1268,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1433,6 +1437,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1497,6 +1502,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2209,6 +2215,7 @@ impl Session { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ) .await; @@ -2512,6 +2519,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2522,6 +2530,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2587,6 +2596,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2699,6 +2709,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4891,6 +4902,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4917,16 +4929,19 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } Op::UserInput { items, final_output_json_schema, + submission_type, } => ( items, SessionSettingsUpdate { final_output_json_schema: Some(final_output_json_schema), + submission_type, ..Default::default() }, ), @@ -5733,6 +5748,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6371,7 +6387,11 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .unwrap_or(SubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9aea3e5594..82b78e9030 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -186,6 +186,7 @@ pub(crate) async fn run_codex_thread_one_shot( io.submit(Op::UserInput { items: input, final_output_json_schema, + submission_type: None, }) .await?; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5edfba863..f58dea067b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1197,6 +1197,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1252,6 +1253,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2811,6 +2813,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3149,6 +3152,7 @@ fn op_kind_distinguishes_turn_ops() { Op::UserInput { items: vec![], final_output_json_schema: None, + submission_type: None, } .kind(), "user_input" @@ -3179,6 +3183,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3653,6 +3658,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 5f09c248a4..517bdf2a53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2004,6 +2004,7 @@ async fn send_input_accepts_structured_items() { }, ], final_output_json_schema: None, + submission_type: None, }; let captured = manager .captured_ops() diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index af3c70b74e..797c3cf37b 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -51,6 +51,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -105,6 +106,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -203,6 +206,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -221,6 +225,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a4336bd7d0..13517bcc9a 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -387,6 +387,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -749,6 +750,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -947,6 +949,7 @@ async fn includes_base_instructions_override_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -993,6 +996,7 @@ async fn omits_explicit_null_base_instructions_from_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1041,6 +1045,7 @@ async fn chatgpt_auth_sends_correct_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1154,6 +1159,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1190,6 +1196,7 @@ async fn includes_user_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1335,6 +1343,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1391,6 +1400,7 @@ async fn omits_apps_guidance_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1430,6 +1440,7 @@ async fn omits_environment_context_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1484,6 +1495,7 @@ async fn skills_append_to_developer_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1535,6 +1547,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1577,6 +1590,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1617,6 +1631,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1687,6 +1702,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1729,6 +1745,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1801,6 +1818,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1844,6 +1862,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1900,6 +1919,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1936,6 +1956,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1981,6 +2002,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2025,6 +2047,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2074,6 +2097,7 @@ async fn includes_developer_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2364,6 +2388,7 @@ async fn token_count_includes_rate_limits_snapshot() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2529,6 +2554,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -2603,6 +2629,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2615,6 +2642,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2697,6 +2725,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2804,6 +2833,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2889,6 +2919,7 @@ async fn env_var_overrides_loaded_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2950,6 +2981,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2963,6 +2995,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2976,6 +3009,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 7ff4a9f88d..4ca6b04e6b 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1003,6 +1003,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1088,6 +1089,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting invalid request events"); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..f6d82bd7ca 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -84,6 +84,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -143,6 +144,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -191,6 +193,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +244,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +311,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -364,6 +369,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -391,6 +397,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -474,6 +482,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -532,6 +541,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -562,6 +572,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -621,6 +632,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -651,6 +663,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -714,6 +727,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -727,6 +741,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -783,6 +798,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..ebdd6babd1 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -245,6 +245,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -267,6 +268,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -443,6 +445,7 @@ async fn manual_compact_uses_custom_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -587,6 +590,7 @@ async fn manual_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -750,6 +754,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1262,6 +1268,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1443,6 +1451,7 @@ async fn auto_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1521,6 +1530,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1533,6 +1543,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1545,6 +1556,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1668,6 +1680,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1772,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1798,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1910,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1960,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -2037,6 +2054,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2049,6 +2067,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2061,6 +2080,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2147,6 +2167,7 @@ async fn manual_compact_retries_after_context_window_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2258,6 +2279,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -2350,6 +2372,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2365,6 +2388,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2380,6 +2404,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2541,6 +2566,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2643,6 +2669,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2839,6 +2866,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2956,6 +2984,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -3015,6 +3044,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3050,6 +3080,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }, ], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3149,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3206,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -3260,6 +3293,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user"); @@ -3272,6 +3306,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit second user"); @@ -3342,6 +3377,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit follow-up user input"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index dd99e0f391..dbea43e643 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -235,6 +235,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -249,6 +250,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -379,6 +381,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -453,6 +456,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -464,6 +468,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -579,6 +584,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -590,6 +596,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -607,6 +614,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -704,6 +712,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -715,6 +724,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -806,6 +816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -820,6 +831,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -905,6 +917,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -919,6 +932,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -987,6 +1001,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1065,6 +1080,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1147,6 +1163,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1288,6 +1305,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1303,6 +1321,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1325,6 +1344,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1419,6 +1439,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1433,6 +1454,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1502,6 +1524,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1513,6 +1536,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1577,6 +1601,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1635,6 +1660,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1648,6 +1674,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1720,6 +1747,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1734,6 +1762,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1814,6 +1843,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1827,6 +1857,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1915,6 +1946,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1942,6 +1974,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2035,6 +2068,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2119,6 +2153,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2145,6 +2180,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2261,6 +2297,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2272,6 +2309,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -2354,6 +2392,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2429,6 +2468,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2512,6 +2552,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2526,6 +2567,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2605,6 +2647,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 8f4f2ae755..4cfa7c1cb0 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -808,6 +808,7 @@ async fn user_turn(conversation: &Arc, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index e24cb74d7c..0b1c645be1 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -53,6 +53,7 @@ async fn fork_thread_twice_drops_to_first_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c6e8a48d27..07479cd9b7 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -904,6 +904,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -920,6 +921,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..3b2bc50644 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -86,6 +86,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { .submit(Op::UserInput { items: vec![expected_input.clone()], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -142,6 +143,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -200,6 +202,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -259,6 +262,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -385,6 +390,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -438,6 +444,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -530,6 +537,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +615,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +725,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +913,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1028,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1085,6 +1097,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -1144,6 +1157,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..02e0d81dd6 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -322,6 +326,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -366,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -421,6 +427,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -466,6 +473,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 96df3fd2f1..936b50ae2a 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -107,6 +107,7 @@ async fn responses_api_emits_api_request_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -150,6 +151,7 @@ async fn process_sse_emits_tracing_for_output_item() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -193,6 +195,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -237,6 +240,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -301,6 +305,7 @@ async fn process_sse_failed_event_records_response_error_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -363,6 +368,7 @@ async fn process_sse_failed_event_logs_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -412,6 +418,7 @@ async fn process_sse_failed_event_logs_missing_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -470,6 +477,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -522,6 +530,7 @@ async fn process_sse_emits_completed_telemetry() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -594,6 +603,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -678,6 +688,7 @@ async fn record_responses_sets_span_fields_for_response_events() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -762,6 +773,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -835,6 +847,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -918,6 +931,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -986,6 +1000,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1094,6 +1109,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1145,6 +1161,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1211,6 +1228,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1277,6 +1295,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1343,6 +1362,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1409,6 +1429,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1476,6 +1497,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 25a08120ae..0467c27250 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -96,6 +96,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap_or_else(|err| panic!("submit user input: {err}")); @@ -240,6 +241,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -256,6 +258,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d1738db902..53960bd728 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -53,6 +53,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -90,6 +91,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -117,6 +119,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -160,6 +163,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -214,6 +219,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +247,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -297,6 +304,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -326,6 +334,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -339,6 +348,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -396,6 +406,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -425,6 +436,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -444,6 +456,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -476,6 +489,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -528,6 +542,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index cd0ad330ae..7bc2532cbe 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -213,6 +213,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -288,6 +289,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -360,6 +362,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..764f72b60b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -151,6 +151,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -162,6 +163,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -247,6 +249,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -258,6 +261,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -320,6 +324,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -412,6 +418,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -448,6 +455,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -530,6 +538,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -681,6 +690,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -712,6 +722,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +836,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +858,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +964,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +986,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 466680e6a8..24cdd5c64f 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -46,6 +46,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index b169e229a5..de0332c340 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2062,6 +2062,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fb0125e66e..2f4fe36217 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -45,6 +45,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -92,6 +93,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index a77ad60b5a..155482cd60 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -91,6 +91,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { text_elements: text_elements.clone(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -176,6 +177,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -265,6 +267,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -305,6 +308,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -320,6 +324,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -433,6 +439,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 503bc8745e..cf7ef74293 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -726,6 +726,7 @@ async fn review_history_surfaces_in_parent_session() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d41785bd4e..9e03afdc4c 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -427,6 +427,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index d99c3865c7..3dcd754bcf 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -405,6 +405,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index c5aa57ce74..1f4283dd3e 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -98,6 +98,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -117,6 +118,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 0c4d7cb49d..bfaa123fcc 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -82,6 +82,7 @@ async fn retries_on_early_close() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7470717a04..66cf9a427c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -179,6 +179,7 @@ async fn submit_unified_exec_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -259,6 +260,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1747,6 +1749,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1840,6 +1843,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2312,6 +2316,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2421,6 +2426,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index a2303c0ecf..dc383f00dd 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -62,6 +62,7 @@ mv "${tmp_path}" "${payload_path}""#, text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index b213e305e4..36f5d28541 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 76f135d205..3d9fffbef5 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -109,6 +109,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8c849ca806..9baba93215 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -723,6 +723,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 64d553bbff..8ab7af20fe 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -114,6 +114,7 @@ pub async fn run_codex_tool_session( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, trace: None, }; @@ -161,6 +162,7 @@ pub async fn run_codex_tool_session_reply( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f2e90196fc..f67f783181 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Default, + Queued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,9 @@ pub enum Op { /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Similar to [`Op::UserInput`], but contains additional context required @@ -301,6 +311,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -517,6 +531,7 @@ impl From> for Op { Op::UserInput { items: value, final_output_json_schema: None, + submission_type: None, } } } @@ -4484,6 +4499,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4501,6 +4517,7 @@ mod tests { Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, } ); @@ -4520,6 +4537,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: Some(schema.clone()), + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4535,6 +4553,27 @@ mod tests { Ok(()) } + #[test] + fn user_input_serialization_includes_submission_type_when_some() -> Result<()> { + let op = Op::UserInput { + items: Vec::new(), + final_output_json_schema: None, + submission_type: Some(SubmissionType::Queued), + }; + + let json_op = serde_json::to_value(op)?; + assert_eq!( + json_op, + json!({ + "type": "user_input", + "items": [], + "submission_type": "queued", + }) + ); + + Ok(()) + } + #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dad10fa766..13bacdff82 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2235,6 +2235,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2317,6 +2318,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 03072632bc..13ec3a393d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -427,6 +428,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -445,6 +447,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 86e11b49c4..4a1da4366c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5559,6 +5560,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5774,6 +5783,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7235,8 +7245,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::Queued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..d075d939dd 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::Queued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From 19caf41a219b9f513b1f0574a4ffdb60ed4ab265 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 16:37:21 -0700 Subject: [PATCH 205/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 460 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 45 ++ codex-rs/analytics/src/facts.rs | 44 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 377 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 ++ codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 ++++++ codex-rs/core/src/codex.rs | 65 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1335 insertions(+), 45 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..1fe77760ee 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,43 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +130,179 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1012,277 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + submission_type: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }) + ); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["product_client_id"], + json!(originator().value) + ); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..1947fd506d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,47 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..136c7a325f 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,14 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +38,41 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +132,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..3acf8198a1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,8 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..80e6786af5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,143 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + return; + } + let product_client_id = turn_state + .connection_id + .and_then(|connection_id| self.connections.get(&connection_id)) + .map(|connection_state| connection_state.app_server_client.product_client_id.clone()) + .unwrap_or_else(|| originator().value); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + product_client_id, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + product_client_id: String, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id, + submission_type, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f5c60fe9dc..b656df0207 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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,6 +167,7 @@ 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, @@ -201,6 +203,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -217,6 +223,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1720,6 +1727,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1897,6 +1905,7 @@ 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 { @@ -1911,6 +1920,10 @@ 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; @@ -2103,6 +2116,7 @@ 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>, ) { @@ -2123,6 +2137,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2132,6 +2147,7 @@ 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>, ) { @@ -2147,6 +2163,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2886,6 +2903,7 @@ 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; @@ -3007,6 +3025,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3021,6 +3040,7 @@ 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(), @@ -3329,6 +3349,13 @@ 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(), }; @@ -3737,6 +3764,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3785,6 +3813,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3832,6 +3861,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4098,6 +4128,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4119,6 +4150,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4130,6 +4162,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9282720828..8a05de6f54 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6670,6 +6670,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7446,6 +7455,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 70c388a19d..a658811eb4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -352,6 +358,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dce76bf3d2..283a165aa3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +187,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -428,6 +430,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -482,6 +485,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -685,6 +689,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1538,6 +1543,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1946,11 +1952,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2268,6 +2276,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6046,6 +6059,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6332,6 +6347,46 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index aef0a92d76..9aea3e5594 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 29fe9df89c..a5edfba863 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2629,6 +2629,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index bffd0cbfd6..a6f3f0c72b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -517,6 +517,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 15303a58ff..a4336bd7d0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1140,6 +1140,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1f199b149b..f2e90196fc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2263,6 +2263,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 48dc8c21d0f1f8b2bf929a44b609bc5507227e26 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 16:38:42 -0700 Subject: [PATCH 206/383] [codex-analytics] add queued submission metadata --- .../analytics/src/analytics_client_tests.rs | 89 ++++++++++++++- .../schema/json/ClientRequest.json | 18 +++ .../codex_app_server_protocol.schemas.json | 18 +++ .../codex_app_server_protocol.v2.schemas.json | 18 +++ .../schema/json/v2/TurnStartParams.json | 18 +++ .../schema/typescript/SubmissionType.ts | 5 + .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/TurnStartParams.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 1 + .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 35 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 107 ++++++++++++++++++ codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex.rs | 22 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/guardian/review_session.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 34 ++++++ .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 16 +++ codex-rs/core/tests/suite/compact.rs | 36 ++++++ codex-rs/core/tests/suite/compact_remote.rs | 43 +++++++ .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/fork_thread.rs | 1 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 14 +++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 +++ .../core/tests/suite/model_visible_layout.rs | 8 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/otel.rs | 22 ++++ codex-rs/core/tests/suite/pending_input.rs | 3 + .../core/tests/suite/permissions_messages.rs | 15 +++ codex-rs/core/tests/suite/personality.rs | 13 +++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 15 +++ codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_compression.rs | 2 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/resume.rs | 7 ++ codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/search_tool.rs | 1 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 6 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 +++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/protocol/src/protocol.rs | 39 +++++++ codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app_command.rs | 6 + codex-rs/tui/src/app_server_session.rs | 3 + codex-rs/tui/src/chatwidget.rs | 17 ++- .../chatwidget/tests/composer_submission.rs | 23 ++++ 82 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1fe77760ee..cbec82081f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -210,7 +210,7 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { turn_id: turn_id.to_string(), thread_id: "thread-2".to_string(), num_input_images: 1, - submission_type: None, + submission_type: Some(codex_protocol::protocol::SubmissionType::Default), model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -1020,7 +1020,7 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), product_client_id: "codex-tui".to_string(), - submission_type: None, + submission_type: Some(codex_protocol::protocol::SubmissionType::Default), model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1061,7 +1061,7 @@ fn turn_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "product_client_id": "codex-tui", - "submission_type": null, + "submission_type": "default", "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1135,6 +1135,89 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); } +#[tokio::test] +async fn queued_submission_type_emits_queued_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + let mut resolved_config = sample_turn_resolved_config("turn-2"); + resolved_config.submission_type = Some(codex_protocol::protocol::SubmissionType::Queued); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + resolved_config, + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["submission_type"], json!("queued")); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 5039a0bfe7..c74fc23a95 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2515,6 +2515,13 @@ }, "type": "object" }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -3283,6 +3290,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 da21699875..435bcbf2f5 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 @@ -12202,6 +12202,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14763,6 +14770,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { 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 a66c34e14d..1f6f2ed33a 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 @@ -10057,6 +10057,13 @@ } ] }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TerminalInteractionNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12618,6 +12625,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..f85604df57 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -354,6 +354,13 @@ ], "type": "object" }, + "SubmissionType": { + "enum": [ + "default", + "queued" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -595,6 +602,17 @@ ], "description": "Override the service tier for this turn and subsequent turns." }, + "submissionType": { + "anyOf": [ + { + "$ref": "#/definitions/SubmissionType" + }, + { + "type": "null" + } + ], + "description": "Metadata describing how the prompt was submitted." + }, "summary": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts new file mode 100644 index 0000000000..2f4eda6e7e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/SubmissionType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubmissionType = "default" | "queued"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7ffc15e83d..a15d949c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -65,6 +65,7 @@ export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; +export type { SubmissionType } from "./SubmissionType"; export type { ThreadId } from "./ThreadId"; export type { Tool } from "./Tool"; export type { Verbosity } from "./Verbosity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..9697838d9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -6,6 +6,7 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; +import type { SubmissionType } from "../SubmissionType"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -45,6 +46,9 @@ personality?: Personality | null, /** * this turn. */ outputSchema?: JsonValue | null, /** + * Metadata describing how the prompt was submitted. + */ +submissionType?: SubmissionType | null, /** * EXPERIMENTAL - Set a pre-set collaboration mode. * Takes precedence over model, reasoning_effort, and developer instructions if set. * diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e0d22bfa24..ac3f15c59c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -4058,6 +4059,11 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub output_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub submission_type: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode. /// Takes precedence over model, reasoning_effort, and developer instructions if set. /// @@ -8420,6 +8426,7 @@ mod tests { service_tier: None, effort: None, summary: None, + submission_type: None, output_schema: None, collaboration_mode: None, personality: None, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8a05de6f54..759cc60017 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6650,6 +6650,7 @@ impl CodexMessageProcessor { Op::UserInput { items: mapped_items, final_output_json_schema: params.output_schema, + submission_type: params.submission_type, }, ) .await; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..fbab5a7726 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -615,6 +615,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { effort: None, summary: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..d0fe9bbeb9 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -173,6 +173,41 @@ pub(crate) async fn wait_for_analytics_event( .await? } +pub(crate) async fn wait_for_analytics_turn_event( + server: &MockServer, + read_timeout: Duration, + turn_id: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events.iter().find(|event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == turn_id + }) { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() 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 a658811eb4..0ffdad7d0c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -55,6 +55,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::SubmissionType; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -69,6 +70,7 @@ use tokio::time::timeout; use super::analytics::enable_analytics_capture; use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_analytics_turn_event; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -419,7 +421,9 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["submission_type"], "default"); assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["is_first_turn"], true); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); @@ -491,6 +495,107 @@ async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Res payload.is_err(), "turn analytics should be gated off when general_analytics is disabled" ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_tracks_second_turn_as_not_first_and_queued_submission_analytics() -> Result<()> +{ + let responses = vec![ + create_final_assistant_message_sse_response("Done 1")?, + create_final_assistant_message_sse_response("Done 2")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let first_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)), + ) + .await??; + let TurnStartResponse { turn: first_turn } = to_response::(first_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let first_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &first_turn.id).await?; + assert_eq!( + first_turn_event["event_params"]["submission_type"], + "default" + ); + assert_eq!(first_turn_event["event_params"]["is_first_turn"], true); + + let second_turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + submission_type: Some(SubmissionType::Queued), + ..Default::default() + }) + .await?; + let second_turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)), + ) + .await??; + let TurnStartResponse { turn: second_turn } = + to_response::(second_turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_event = + wait_for_analytics_turn_event(&server, DEFAULT_READ_TIMEOUT, &second_turn.id).await?; + assert_eq!( + second_turn_event["event_params"]["submission_type"], + "queued" + ); + assert_eq!(second_turn_event["event_params"]["is_first_turn"], false); + Ok(()) } @@ -1648,6 +1753,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) @@ -1681,6 +1787,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { summary: Some(ReasoningSummary::Auto), service_tier: None, personality: None, + submission_type: None, output_schema: None, collaboration_mode: None, }) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 54693228fa..17e620e6ba 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -425,6 +425,7 @@ async fn send_input_submits_user_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -571,6 +572,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness @@ -678,6 +680,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ); let captured = harness diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 283a165aa3..c758b07736 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -119,6 +119,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -885,6 +886,7 @@ pub(crate) struct TurnContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, + pub(crate) submission_type: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -1003,6 +1005,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), app_server_client_name: self.app_server_client_name.clone(), + submission_type: self.submission_type, developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -1265,6 +1268,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) submission_type: Option, } pub(crate) struct AppServerClientMetadata { @@ -1433,6 +1437,7 @@ impl Session { network: Option, environment: Option>, sub_id: String, + submission_type: Option, js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { @@ -1497,6 +1502,7 @@ impl Session { current_date: Some(current_date), timezone: Some(timezone), app_server_client_name: session_configuration.app_server_client_name.clone(), + submission_type, developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -2209,6 +2215,7 @@ impl Session { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, ) .await; @@ -2512,6 +2519,7 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + updates.submission_type, sandbox_policy_changed, ) .await) @@ -2522,6 +2530,7 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + submission_type: Option, sandbox_policy_changed: bool, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); @@ -2587,6 +2596,7 @@ impl Session { .map(StartedNetworkProxy::proxy), self.services.environment.clone(), sub_id, + submission_type, Arc::clone(&self.js_repl), skills_outcome, ); @@ -2699,6 +2709,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*submission_type*/ None, /*sandbox_policy_changed*/ false, ) .await @@ -4891,6 +4902,7 @@ mod handlers { items, collaboration_mode, personality, + submission_type, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -4917,16 +4929,19 @@ mod handlers { personality, app_server_client_name: None, app_server_client_version: None, + submission_type, }, ) } Op::UserInput { items, final_output_json_schema, + submission_type, } => ( items, SessionSettingsUpdate { final_output_json_schema: Some(final_output_json_schema), + submission_type, ..Default::default() }, ), @@ -5733,6 +5748,7 @@ async fn spawn_review_thread( current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), app_server_client_name: parent_turn_context.app_server_client_name.clone(), + submission_type: None, developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -6371,7 +6387,11 @@ async fn track_turn_resolved_config_analytics( matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) .count(), - submission_type: None, + submission_type: Some( + turn_context + .submission_type + .unwrap_or(SubmissionType::Default), + ), model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9aea3e5594..82b78e9030 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -186,6 +186,7 @@ pub(crate) async fn run_codex_thread_one_shot( io.submit(Op::UserInput { items: input, final_output_json_schema, + submission_type: None, }) .await?; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a5edfba863..f58dea067b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1197,6 +1197,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1252,6 +1253,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2811,6 +2813,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, ); @@ -3149,6 +3152,7 @@ fn op_kind_distinguishes_turn_ops() { Op::UserInput { items: vec![], final_output_json_schema: None, + submission_type: None, } .kind(), "user_input" @@ -3179,6 +3183,7 @@ async fn user_turn_updates_approvals_reviewer() { final_output_json_schema: None, collaboration_mode: None, personality: config.personality, + submission_type: None, }, ) .await; @@ -3653,6 +3658,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( /*network*/ None, Some(environment), "turn_id".to_string(), + /*submission_type*/ None, Arc::clone(&js_repl), skills_outcome, )); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 368b812e56..fe472eb8be 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -523,6 +523,7 @@ async fn run_review_on_session( final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, personality: params.personality, + submission_type: None, }) .await }), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 5f09c248a4..517bdf2a53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2004,6 +2004,7 @@ async fn send_input_accepts_structured_items() { }, ], final_output_json_schema: None, + submission_type: None, }; let captured = manager .captured_ops() diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a6f3f0c72b..52f05e1caa 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -760,6 +760,7 @@ impl TestCodex { service_tier, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index af3c70b74e..797c3cf37b 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -51,6 +51,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -105,6 +106,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -203,6 +206,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -221,6 +225,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a8b429c0dd..c6a1b22970 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -368,6 +368,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -978,6 +979,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1060,6 +1062,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1212,6 +1215,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1277,6 +1281,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1350,6 +1355,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1423,6 +1429,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f7874eaf45..837c1c331c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -597,6 +597,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a4336bd7d0..13517bcc9a 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -387,6 +387,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -749,6 +750,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -947,6 +949,7 @@ async fn includes_base_instructions_override_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -993,6 +996,7 @@ async fn omits_explicit_null_base_instructions_from_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1041,6 +1045,7 @@ async fn chatgpt_auth_sends_correct_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1154,6 +1159,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1190,6 +1196,7 @@ async fn includes_user_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1335,6 +1343,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1391,6 +1400,7 @@ async fn omits_apps_guidance_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1430,6 +1440,7 @@ async fn omits_environment_context_when_configured_off() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1484,6 +1495,7 @@ async fn skills_append_to_developer_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1535,6 +1547,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1577,6 +1590,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1617,6 +1631,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1687,6 +1702,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; @@ -1729,6 +1745,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1801,6 +1818,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1844,6 +1862,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1900,6 +1919,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1936,6 +1956,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1981,6 +2002,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2025,6 +2047,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2074,6 +2097,7 @@ async fn includes_developer_instructions_message_in_request() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2364,6 +2388,7 @@ async fn token_count_includes_rate_limits_snapshot() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2529,6 +2554,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -2603,6 +2629,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2615,6 +2642,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2697,6 +2725,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -2804,6 +2833,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2889,6 +2919,7 @@ async fn env_var_overrides_loaded_auth() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2950,6 +2981,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2963,6 +2995,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2976,6 +3009,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 7ff4a9f88d..4ca6b04e6b 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1003,6 +1003,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1088,6 +1089,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submission should succeed while emitting invalid request events"); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9ddc169a60..94b799a6af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2368,6 +2368,7 @@ text( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index df8d6e4fe4..f6d82bd7ca 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -84,6 +84,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -143,6 +144,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -191,6 +193,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +244,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -307,6 +311,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -364,6 +369,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -391,6 +397,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -474,6 +482,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -532,6 +541,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -562,6 +572,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -621,6 +632,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -651,6 +663,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -714,6 +727,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -727,6 +741,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -783,6 +798,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index df8fad124d..ebdd6babd1 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -245,6 +245,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -267,6 +268,7 @@ async fn summarize_context_three_requests_and_instructions() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -443,6 +445,7 @@ async fn manual_compact_uses_custom_prompt() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -587,6 +590,7 @@ async fn manual_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -750,6 +754,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1262,6 +1268,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1275,6 +1282,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1443,6 +1451,7 @@ async fn auto_compact_emits_context_compaction_items() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1521,6 +1530,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1533,6 +1543,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1545,6 +1556,7 @@ async fn auto_compact_starts_after_turn_started() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1668,6 +1680,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .unwrap(); @@ -1759,6 +1772,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -1784,6 +1798,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -1895,6 +1910,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit pre-resume turn"); @@ -1944,6 +1960,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit resumed user turn"); @@ -2037,6 +2054,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2049,6 +2067,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2061,6 +2080,7 @@ async fn auto_compact_persists_rollout_entries() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2147,6 +2167,7 @@ async fn manual_compact_retries_after_context_window_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2258,6 +2279,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -2350,6 +2372,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2365,6 +2388,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2380,6 +2404,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2541,6 +2566,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2643,6 +2669,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2839,6 +2866,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -2956,6 +2984,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -3015,6 +3044,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3050,6 +3080,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }, ], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user input"); @@ -3149,6 +3180,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit first user turn"); @@ -3174,6 +3206,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await .expect("submit second user turn"); @@ -3260,6 +3293,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit first user"); @@ -3272,6 +3306,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit second user"); @@ -3342,6 +3377,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit follow-up user input"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index dd99e0f391..dbea43e643 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -235,6 +235,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -249,6 +250,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -379,6 +381,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -453,6 +456,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -464,6 +468,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -579,6 +584,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -590,6 +596,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -607,6 +614,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -704,6 +712,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -715,6 +724,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -806,6 +816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -820,6 +831,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&baseline_codex, |event| { @@ -905,6 +917,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -919,6 +932,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&override_codex, |event| { @@ -987,6 +1001,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1065,6 +1080,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1147,6 +1163,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1288,6 +1305,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1303,6 +1321,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1325,6 +1344,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1419,6 +1439,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1433,6 +1454,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1502,6 +1524,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1513,6 +1536,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1577,6 +1601,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1635,6 +1660,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1648,6 +1674,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1720,6 +1747,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1734,6 +1762,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1814,6 +1843,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1827,6 +1857,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1915,6 +1946,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1942,6 +1974,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2035,6 +2068,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2119,6 +2153,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2145,6 +2180,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2261,6 +2297,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2272,6 +2309,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -2354,6 +2392,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2429,6 +2468,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2512,6 +2552,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2526,6 +2567,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2605,6 +2647,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 8f4f2ae755..4cfa7c1cb0 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -808,6 +808,7 @@ async fn user_turn(conversation: &Arc, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .expect("submit user turn"); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index fb055c970f..6de4141e6c 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -59,6 +59,7 @@ async fn submit_user_turn( service_tier: None, collaboration_mode, personality: None, + submission_type: None, }) .await?; Ok(()) @@ -140,6 +141,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index e24cb74d7c..0b1c645be1 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -53,6 +53,7 @@ async fn fork_thread_twice_drops_to_first_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c6e8a48d27..07479cd9b7 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -904,6 +904,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -920,6 +921,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 8195bd0a86..0594e48f31 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -130,6 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index f949cd4b97..3b2bc50644 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -86,6 +86,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { .submit(Op::UserInput { items: vec![expected_input.clone()], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -142,6 +143,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -200,6 +202,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -259,6 +262,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -385,6 +390,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -438,6 +444,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -530,6 +537,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -607,6 +615,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -716,6 +725,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -903,6 +913,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1017,6 +1028,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; @@ -1085,6 +1097,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -1144,6 +1157,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 3b6f3e3f9c..027e34edbd 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -88,6 +88,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f..a0d46f2072 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -69,6 +69,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b14267af49..0ebd5e20b0 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -134,6 +134,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +172,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -268,6 +271,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -410,6 +414,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -431,6 +436,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -539,6 +545,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -560,6 +567,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -671,6 +679,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -692,6 +701,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -805,6 +815,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -834,6 +845,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -989,6 +1001,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1048,6 +1061,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 13e2819141..02e0d81dd6 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -129,6 +129,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -153,6 +154,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -232,6 +234,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -256,6 +259,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { @@ -322,6 +326,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -366,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -421,6 +427,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -466,6 +473,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 94f2424db4..15cbc82d4b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -105,6 +105,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index daf3e89462..9fbbcdb93c 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 96df3fd2f1..936b50ae2a 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -107,6 +107,7 @@ async fn responses_api_emits_api_request_event() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -150,6 +151,7 @@ async fn process_sse_emits_tracing_for_output_item() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -193,6 +195,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -237,6 +240,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -301,6 +305,7 @@ async fn process_sse_failed_event_records_response_error_message() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -363,6 +368,7 @@ async fn process_sse_failed_event_logs_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -412,6 +418,7 @@ async fn process_sse_failed_event_logs_missing_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -470,6 +477,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -522,6 +530,7 @@ async fn process_sse_emits_completed_telemetry() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -594,6 +603,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -678,6 +688,7 @@ async fn record_responses_sets_span_fields_for_response_events() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -762,6 +773,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -835,6 +847,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -918,6 +931,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -986,6 +1000,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1094,6 +1109,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1145,6 +1161,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1211,6 +1228,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1277,6 +1295,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1343,6 +1362,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1409,6 +1429,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -1476,6 +1497,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 25a08120ae..0467c27250 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -96,6 +96,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap_or_else(|err| panic!("submit user input: {err}")); @@ -240,6 +241,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -256,6 +258,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d1738db902..53960bd728 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -53,6 +53,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -90,6 +91,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -117,6 +119,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -160,6 +163,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -214,6 +219,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -241,6 +247,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -297,6 +304,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -326,6 +334,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -339,6 +348,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -396,6 +406,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -425,6 +436,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -444,6 +456,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -476,6 +489,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -528,6 +542,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8a0cbdcb63..ea2507c766 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -111,6 +111,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -162,6 +163,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -220,6 +222,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -284,6 +287,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -336,6 +340,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -374,6 +379,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -441,6 +447,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -479,6 +486,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -559,6 +567,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -597,6 +606,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -714,6 +724,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + submission_type: None, }) .await?; @@ -833,6 +844,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -871,6 +883,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index cd0ad330ae..7bc2532cbe 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -213,6 +213,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -288,6 +289,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -360,6 +362,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d34e691de..764f72b60b 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -151,6 +151,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -162,6 +163,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -247,6 +249,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -258,6 +261,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -320,6 +324,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -412,6 +418,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -448,6 +455,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -530,6 +538,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -681,6 +690,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -712,6 +722,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -825,6 +836,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -846,6 +858,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -951,6 +964,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -972,6 +986,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu collaboration_mode: None, final_output_json_schema: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 466680e6a8..24cdd5c64f 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -46,6 +46,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index b169e229a5..de0332c340 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2062,6 +2062,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9559397b7e..5d08e69a29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -184,6 +184,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -247,6 +248,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -407,6 +409,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index fb0125e66e..2f4fe36217 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -45,6 +45,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -92,6 +93,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..6e25adb228 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -202,6 +202,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a41..d44f47d931 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -153,6 +153,7 @@ async fn submit_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c21..952c696653 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -153,6 +153,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), personality: None, + submission_type: None, }) .await?; @@ -264,6 +265,7 @@ where service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index a77ad60b5a..155482cd60 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -91,6 +91,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { text_elements: text_elements.clone(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -176,6 +177,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; @@ -265,6 +267,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -305,6 +308,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -320,6 +324,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -433,6 +439,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 503bc8745e..cf7ef74293 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -726,6 +726,7 @@ async fn review_history_surfaces_in_parent_session() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 68c2bc0106..cb38a90919 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -138,6 +138,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -309,6 +310,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -514,6 +516,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -630,6 +633,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -793,6 +797,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1041,6 +1046,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16a..ef8c086275 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -53,6 +53,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -152,6 +153,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -238,6 +240,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -288,6 +291,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d41785bd4e..9e03afdc4c 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -427,6 +427,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 0e044f3922..960ebdb945 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -172,6 +172,7 @@ async fn run_snapshot_command_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -263,6 +264,7 @@ async fn run_shell_command_snapshot_with_options( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -334,6 +336,7 @@ async fn run_tool_turn_on_harness( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -568,6 +571,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f94..15d096268a 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -59,6 +59,7 @@ async fn submit_turn_with_policies( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 388618b56a..690de66893 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -82,6 +82,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index d99c3865c7..3dcd754bcf 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -405,6 +405,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index c5aa57ce74..1f4283dd3e 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -98,6 +98,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); @@ -117,6 +118,7 @@ async fn continue_after_stream_error() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 0c4d7cb49d..bfaa123fcc 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -82,6 +82,7 @@ async fn retries_on_early_close() { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 9594195a5b..296b5c4c09 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -93,6 +93,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -164,6 +165,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -245,6 +247,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -341,6 +344,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -445,6 +449,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index faff0b2e09..16b0a2edd7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -50,6 +50,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -367,6 +368,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 3f4a9e5a64..5e4c980228 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -496,6 +496,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7470717a04..66cf9a427c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -179,6 +179,7 @@ async fn submit_unified_exec_turn( service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -259,6 +260,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1747,6 +1749,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1840,6 +1843,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2312,6 +2316,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -2421,6 +2426,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index a2303c0ecf..dc383f00dd 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -62,6 +62,7 @@ mv "${tmp_path}" "${payload_path}""#, text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index f6abb5d621..c3ff787392 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -185,6 +185,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index b213e305e4..36f5d28541 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -164,6 +164,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -273,6 +274,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -414,6 +416,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -520,6 +523,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -612,6 +616,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -712,6 +717,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -823,6 +829,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -925,6 +932,7 @@ await codex.emitImage(out); service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1045,6 +1053,7 @@ console.log(out.type); summary: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1138,6 +1147,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1214,6 +1224,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1295,6 +1306,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1425,6 +1437,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; @@ -1500,6 +1513,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64..60db8b4bcf 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -165,6 +165,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result service_tier: None, collaboration_mode: None, personality: None, + submission_type: None, }) .await?; diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 76f135d205..3d9fffbef5 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -109,6 +109,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8c849ca806..9baba93215 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -723,6 +723,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { effort: default_effort, summary: None, personality: None, + submission_type: None, output_schema, collaboration_mode: None, }, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 64d553bbff..8ab7af20fe 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -114,6 +114,7 @@ pub async fn run_codex_tool_session( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }, trace: None, }; @@ -161,6 +162,7 @@ pub async fn run_codex_tool_session_reply( text_elements: Vec::new(), }], final_output_json_schema: None, + submission_type: None, }) .await { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f2e90196fc..f67f783181 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,13 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SubmissionType { + Default, + Queued, +} + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { @@ -244,6 +251,9 @@ pub enum Op { /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Similar to [`Op::UserInput`], but contains additional context required @@ -301,6 +311,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Metadata describing how the prompt was submitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + submission_type: Option, }, /// Inter-agent communication that should be recorded as assistant history @@ -517,6 +531,7 @@ impl From> for Op { Op::UserInput { items: value, final_output_json_schema: None, + submission_type: None, } } } @@ -4484,6 +4499,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4501,6 +4517,7 @@ mod tests { Op::UserInput { items: Vec::new(), final_output_json_schema: None, + submission_type: None, } ); @@ -4520,6 +4537,7 @@ mod tests { let op = Op::UserInput { items: Vec::new(), final_output_json_schema: Some(schema.clone()), + submission_type: None, }; let json_op = serde_json::to_value(op)?; @@ -4535,6 +4553,27 @@ mod tests { Ok(()) } + #[test] + fn user_input_serialization_includes_submission_type_when_some() -> Result<()> { + let op = Op::UserInput { + items: Vec::new(), + final_output_json_schema: None, + submission_type: Some(SubmissionType::Queued), + }; + + let json_op = serde_json::to_value(op)?; + assert_eq!( + json_op, + json!({ + "type": "user_input", + "items": [], + "submission_type": "queued", + }) + ); + + Ok(()) + } + #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dad10fa766..13bacdff82 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2235,6 +2235,7 @@ impl App { final_output_json_schema, collaboration_mode, personality, + submission_type, } => { let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { @@ -2317,6 +2318,7 @@ impl App { *service_tier, collaboration_mode.clone(), *personality, + *submission_type, final_output_json_schema.clone(), ) .await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..4a83d1ab95 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SubmissionType; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -51,6 +52,7 @@ pub(crate) enum AppCommandView<'a> { final_output_json_schema: &'a Option, collaboration_mode: &'a Option, personality: &'a Option, + submission_type: &'a Option, }, OverrideTurnContext { cwd: &'a Option, @@ -152,6 +154,7 @@ impl AppCommand { final_output_json_schema: Option, collaboration_mode: Option, personality: Option, + submission_type: Option, ) -> Self { Self(Op::UserTurn { items, @@ -166,6 +169,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }) } @@ -311,6 +315,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, } => AppCommandView::UserTurn { items, cwd, @@ -324,6 +329,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + submission_type, }, Op::OverrideTurnContext { cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 03072632bc..13ec3a393d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SubmissionType; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -427,6 +428,7 @@ impl AppServerSession { service_tier: Option>, collaboration_mode: Option, personality: Option, + submission_type: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); @@ -445,6 +447,7 @@ impl AppServerSession { effort, summary, personality, + submission_type, output_schema, collaboration_mode, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 86e11b49c4..4a1da4366c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -193,6 +193,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::SubmissionType; use codex_protocol::protocol::TerminalInteractionEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -5559,6 +5560,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_type(user_message, /*submission_type*/ None); + } + + fn submit_user_message_with_type( + &mut self, + user_message: UserMessage, + submission_type: Option, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5774,6 +5783,7 @@ impl ChatWidget { /*final_output_json_schema*/ None, collaboration_mode, personality, + submission_type, ); if !self.submit_op(op) { @@ -7235,8 +7245,13 @@ impl ChatWidget { if self.bottom_pane.is_task_running() { return; } + let submission_type = if self.rejected_steers_queue.is_empty() { + Some(SubmissionType::Queued) + } else { + None + }; if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + self.submit_user_message_with_type(user_message, submission_type); } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1bae5d2aa8..d075d939dd 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -642,6 +642,29 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); } +#[tokio::test] +async fn queued_user_message_marks_prompt_queued_submission_type() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.queued_user_messages.push_back(UserMessage { + text: "queued follow up".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); + + chat.maybe_send_next_queued_input(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + submission_type: Some(SubmissionType::Queued), + .. + } => {} + other => panic!("expected queued prompt submission, got {other:?}"), + } +} + #[tokio::test] async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; From d02455011f4d30fcf2e1f0f42e7a6b2d3fa814d8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 22:37:09 -0700 Subject: [PATCH 207/383] [codex-analytics] ingest server requests and responses --- codex-rs/analytics/src/client.rs | 15 ++++ codex-rs/analytics/src/facts.rs | 9 ++ codex-rs/analytics/src/reducer.rs | 7 ++ codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/app-server/src/outgoing_message.rs | 86 +++++++++++++++++++- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..420fcdc1fd 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -227,6 +229,19 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..b9af4db0c4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -81,6 +83,13 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..d6fd017846 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -76,6 +76,13 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..d182489dea 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -239,6 +239,7 @@ impl MessageProcessor { config.chatgpt_base_url.trim_end_matches('/').to_string(), config.analytics_enabled, ); + outgoing.set_analytics_events_client(analytics_events_client.clone()); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..0e50f4df95 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -1,15 +1,18 @@ use std::collections::HashMap; use std::fmt; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +120,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: StdMutex>, } #[derive(Clone)] @@ -209,9 +213,28 @@ impl OutgoingMessageSender { sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client: StdMutex::new(None), } } + pub(crate) fn set_analytics_events_client( + &self, + analytics_events_client: AnalyticsEventsClient, + ) { + let mut client = self + .analytics_events_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *client = Some(analytics_events_client); + } + + fn analytics_events_client(&self) -> Option { + self.analytics_events_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +321,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +344,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +390,11 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + && let Some(analytics_events_client) = self.analytics_events_client() + { + analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +652,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +693,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,6 +879,49 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); From 7806c42750fe3dfb59910f3d32f0cccb4ab585c9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 22:40:06 -0700 Subject: [PATCH 208/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..847b24d22d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..f82ebe6f0a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +100,81 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From 7b338d11db248e1b2a55205d46f41a9c3d1fa697 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 22:48:36 -0700 Subject: [PATCH 209/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 8 +- 6 files changed, 488 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 847b24d22d..7c3ee56761 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 420fcdc1fd..6379564815 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -242,6 +243,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b9af4db0c4..a4b056ee0a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d6fd017846..27aa9330a8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: &CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: &CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 0e50f4df95..407315271b 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -549,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 @@ -573,6 +573,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification.clone()); } } } @@ -583,7 +585,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 @@ -595,6 +597,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From b125d6ef9c50d07de847037ea0a9ed4fce3961a5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 22:37:09 -0700 Subject: [PATCH 210/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client.rs | 17 +++- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/app-server/src/outgoing_message.rs | 86 ++++++++++++++++++- 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..eb511483a4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -353,7 +353,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -397,7 +397,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..31b994d72e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -222,11 +224,24 @@ impl AnalyticsEventsClient { } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..f1ede6a162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -72,15 +74,22 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..62bf19e99a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -65,17 +65,24 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: _connection_id, request_id: _request_id, request: _request, } => {} - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..d182489dea 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -239,6 +239,7 @@ impl MessageProcessor { config.chatgpt_base_url.trim_end_matches('/').to_string(), config.analytics_enabled, ); + outgoing.set_analytics_events_client(analytics_events_client.clone()); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..0e50f4df95 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -1,15 +1,18 @@ use std::collections::HashMap; use std::fmt; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +120,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: StdMutex>, } #[derive(Clone)] @@ -209,9 +213,28 @@ impl OutgoingMessageSender { sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client: StdMutex::new(None), } } + pub(crate) fn set_analytics_events_client( + &self, + analytics_events_client: AnalyticsEventsClient, + ) { + let mut client = self + .analytics_events_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *client = Some(analytics_events_client); + } + + fn analytics_events_client(&self) -> Option { + self.analytics_events_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +321,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +344,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +390,11 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + && let Some(analytics_events_client) = self.analytics_events_client() + { + analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +652,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +693,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,6 +879,49 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); From ca1a5ebb6d6858b0e86178d774ab6bda0e8155b3 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 22:40:06 -0700 Subject: [PATCH 211/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..f82ebe6f0a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +100,81 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From 5d0c941b7186c913e9dcce5ea11436b1c3111dfd Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 7 Apr 2026 22:48:36 -0700 Subject: [PATCH 212/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 8 +- 6 files changed, 488 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 31b994d72e..c897965115 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -242,6 +243,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..b9482ddb31 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: &CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: &CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 0e50f4df95..407315271b 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -549,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 @@ -573,6 +573,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification.clone()); } } } @@ -583,7 +585,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 @@ -595,6 +597,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 421fa0c156188df46ad9e9bda1c30688983ce2c2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 12:37:37 -0700 Subject: [PATCH 213/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client.rs | 17 +++- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 9 +- codex-rs/app-server/src/message_processor.rs | 18 ++-- .../src/message_processor/tracing_tests.rs | 4 + codex-rs/app-server/src/outgoing_message.rs | 86 ++++++++++++++++++- 9 files changed, 158 insertions(+), 17 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..eb511483a4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -353,7 +353,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -397,7 +397,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..31b994d72e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -222,11 +224,24 @@ impl AnalyticsEventsClient { } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..f1ede6a162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -72,15 +74,22 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..62bf19e99a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -65,17 +65,24 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: _connection_id, request_id: _request_id, request: _request, } => {} - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 7bddd8a714..38885e1006 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -56,6 +56,7 @@ use crate::error_code::OVERLOADED_ERROR_CODE; use crate::message_processor::ConnectionSessionState; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; +use crate::message_processor::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessage; @@ -355,7 +356,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new_with_analytics( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -380,12 +388,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: Arc::new(EnvironmentManager::from_env()), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a837d9c754..77f59e0286 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -20,6 +20,7 @@ use std::sync::atomic::AtomicBool; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; +use crate::message_processor::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessageSender; @@ -640,14 +641,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new_with_analytics( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..a2fde2ca67 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -185,6 +185,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -200,12 +201,24 @@ pub(crate) struct MessageProcessorArgs { pub(crate) remote_control_handle: Option, } +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} + impl MessageProcessor { /// Create a new `MessageProcessor`, retaining a handle to the outgoing /// `Sender` so handlers can enqueue messages to be written to stdout. pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, @@ -234,11 +247,6 @@ impl MessageProcessor { }, environment_manager, )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..0115fe51bf 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use super::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -237,8 +238,11 @@ fn build_test_processor( let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..b58eecbdb2 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: Option, } #[derive(Clone)] @@ -203,15 +206,35 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { + #[cfg(test)] pub(crate) fn new(sender: mpsc::Sender) -> Self { + Self::new_inner(sender, None) + } + + pub(crate) fn new_with_analytics( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { + Self::new_inner(sender, Some(analytics_events_client)) + } + + fn new_inner( + sender: mpsc::Sender, + analytics_events_client: Option, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + fn analytics_events_client(&self) -> Option<&AnalyticsEventsClient> { + self.analytics_events_client.as_ref() + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +321,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +344,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +390,11 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + && let Some(analytics_events_client) = self.analytics_events_client() + { + analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +652,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +693,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,6 +879,49 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); From 67bb15e2f8493244e71d60a188224520d8d8c539 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 12:37:37 -0700 Subject: [PATCH 214/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..f82ebe6f0a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +100,81 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From 23e2a64d57e0b92d5649ab3e0f2a952cbb891239 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 12:37:37 -0700 Subject: [PATCH 215/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 8 +- 6 files changed, 488 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 31b994d72e..c897965115 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -242,6 +243,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..b9482ddb31 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: &CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: &CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index b58eecbdb2..cbebb60549 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -549,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 @@ -573,6 +573,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification.clone()); } } } @@ -583,7 +585,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 @@ -595,6 +597,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 9ca7a71d054f07d89f790b328e159af84c9205a5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 13:10:12 -0700 Subject: [PATCH 216/383] [codex-analytics] add compaction analytics event --- .../analytics/src/analytics_client_tests.rs | 178 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 6 + codex-rs/analytics/src/events.rs | 57 ++++++ codex-rs/analytics/src/facts.rs | 45 +++++ codex-rs/analytics/src/lib.rs | 4 + codex-rs/analytics/src/reducer.rs | 72 ++++++- codex-rs/core/src/codex.rs | 14 +- codex-rs/core/src/compact.rs | 156 ++++++++++++++- codex-rs/core/src/compact_remote.rs | 43 ++++- 9 files changed, 565 insertions(+), 10 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..f0d16ecc4a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3,6 +3,7 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -18,6 +19,10 @@ use crate::facts::AnalyticsFact; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionMode; +use crate::facts::CompactionStatus; +use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -254,6 +259,86 @@ fn app_used_event_serializes_expected_shape() { ); } +#[test] +fn compaction_event_serializes_expected_shape() { + let event = TrackEventRequest::Compaction(Box::new(CodexCompactionEventRequest { + event_type: "codex_compaction_event", + event_params: crate::events::codex_compaction_event_params( + CodexAppServerClientMetadata { + product_client_id: "CODEX_CLI".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.2.3".to_string()), + rpc_transport: AppServerRpcTransport::InProcess, + experimental_api_enabled: Some(true), + }, + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + CodexCompactionEvent { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + trigger: CompactionTrigger::AutoMidTurn, + mode: CompactionMode::Remote, + status: CompactionStatus::Completed, + error: None, + started_at: 100, + completed_at: 106, + duration_ms: Some(6543), + input_tokens_before: Some(1000), + input_tokens_after: Some(600), + estimated_tokens_before: Some(1500), + estimated_tokens_after: Some(900), + history_items_before: 50, + history_items_after: 12, + deleted_items_before_remote_compact: Some(3), + }, + ), + })); + + let payload = serde_json::to_value(&event).expect("serialize compaction event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_compaction_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "app_server_client": { + "product_client_id": "CODEX_CLI", + "client_name": "codex-tui", + "client_version": "1.2.3", + "rpc_transport": "in_process", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "trigger": "auto_mid_turn", + "mode": "remote", + "status": "completed", + "error": null, + "started_at": 100, + "completed_at": 106, + "duration_ms": 6543, + "input_tokens_before": 1000, + "input_tokens_after": 600, + "estimated_tokens_before": 1500, + "estimated_tokens_after": 900, + "history_items_before": 50, + "history_items_after": 12, + "deleted_items_before_remote_compact": 3 + } + }) + ); +} + #[test] fn app_used_dedupe_is_keyed_by_turn_and_connector() { let (sender, _receiver) = mpsc::channel(1); @@ -449,6 +534,99 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn compaction_event_waits_for_thread_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(Box::new( + CodexCompactionEvent { + thread_id: "thread-1".to_string(), + turn_id: "turn-compact".to_string(), + trigger: CompactionTrigger::Manual, + mode: CompactionMode::Local, + status: CompactionStatus::Failed, + error: Some("context limit exceeded".to_string()), + started_at: 100, + completed_at: 101, + duration_ms: Some(1200), + input_tokens_before: Some(2000), + input_tokens_after: Some(2000), + estimated_tokens_before: Some(2500), + estimated_tokens_after: Some(2500), + history_items_before: 20, + history_items_after: 20, + deleted_items_before_remote_compact: None, + }, + ))), + &mut events, + ) + .await; + assert!( + events.is_empty(), + "compaction events should wait for client/runtime metadata" + ); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + 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, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-1", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 2); + assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!(payload[1]["event_type"], "codex_compaction_event"); + assert_eq!(payload[1]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[1]["event_params"]["turn_id"], "turn-compact"); + assert_eq!(payload[1]["event_params"]["trigger"], "manual"); + assert_eq!(payload[1]["event_params"]["mode"], "local"); + assert_eq!(payload[1]["event_params"]["status"], "failed"); + assert_eq!( + payload[1]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[1]["event_params"]["runtime"]["codex_rs_version"], + "0.99.0" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..ef9c42cb12 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -178,6 +178,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_compaction(&self, event: crate::facts::CodexCompactionEvent) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Compaction( + Box::new(event), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..c0282f6b5c 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,4 +1,5 @@ use crate::facts::AppInvocation; +use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -37,6 +38,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + Compaction(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +124,34 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexCompactionEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) trigger: crate::facts::CompactionTrigger, + pub(crate) mode: crate::facts::CompactionMode, + pub(crate) status: crate::facts::CompactionStatus, + pub(crate) error: Option, + pub(crate) started_at: u64, + pub(crate) completed_at: u64, + pub(crate) duration_ms: Option, + pub(crate) input_tokens_before: Option, + pub(crate) input_tokens_after: Option, + pub(crate) estimated_tokens_before: Option, + pub(crate) estimated_tokens_after: Option, + pub(crate) history_items_before: usize, + pub(crate) history_items_after: usize, + pub(crate) deleted_items_before_remote_compact: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCompactionEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCompactionEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -201,6 +231,33 @@ pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPlu } } +pub(crate) fn codex_compaction_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + input: CodexCompactionEvent, +) -> CodexCompactionEventParams { + CodexCompactionEventParams { + thread_id: input.thread_id, + turn_id: input.turn_id, + app_server_client, + runtime, + trigger: input.trigger, + mode: input.mode, + status: input.status, + error: input.error, + started_at: input.started_at, + completed_at: input.completed_at, + duration_ms: input.duration_ms, + input_tokens_before: input.input_tokens_before, + input_tokens_after: input.input_tokens_after, + estimated_tokens_before: input.estimated_tokens_before, + estimated_tokens_after: input.estimated_tokens_after, + history_items_before: input.history_items_before, + history_items_after: input.history_items_after, + deleted_items_before_remote_compact: input.deleted_items_before_remote_compact, + } +} + pub(crate) fn codex_plugin_used_metadata( tracking: &TrackEventsContext, plugin: PluginTelemetryMetadata, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..74406cec58 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -63,6 +63,50 @@ pub struct SubAgentThreadStartedInput { pub created_at: u64, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionTrigger { + Manual, + AutoPreTurn, + AutoMidTurn, + ModelDownshift, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionMode { + Local, + Remote, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompactionStatus { + Completed, + Failed, + Interrupted, +} + +#[derive(Clone)] +pub struct CodexCompactionEvent { + pub thread_id: String, + pub turn_id: String, + pub trigger: CompactionTrigger, + pub mode: CompactionMode, + pub status: CompactionStatus, + pub error: Option, + pub started_at: u64, + pub completed_at: u64, + pub duration_ms: Option, + pub input_tokens_before: Option, + pub input_tokens_after: Option, + pub estimated_tokens_before: Option, + pub estimated_tokens_after: Option, + pub history_items_before: usize, + pub history_items_after: usize, + pub deleted_items_before_remote_compact: Option, +} + #[allow(dead_code)] pub(crate) enum AnalyticsFact { Initialize { @@ -89,6 +133,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + Compaction(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..3642c693ec 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -6,6 +6,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use facts::AppInvocation; +pub use facts::CodexCompactionEvent; +pub use facts::CompactionMode; +pub use facts::CompactionStatus; +pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..b0ce41a1e3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,6 +2,7 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -12,6 +13,7 @@ use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; +use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::plugin_state_event_type; @@ -20,6 +22,7 @@ use crate::events::thread_source_name; use crate::facts::AnalyticsFact; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexCompactionEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -40,6 +43,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + thread_connections: HashMap, + pending_compactions: HashMap>, } struct ConnectionState { @@ -81,6 +86,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::Compaction(input) => { + self.ingest_compaction(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -254,6 +262,9 @@ impl AnalyticsReducer { _ => return, }; let thread_source: SessionSource = thread.source.into(); + let thread_id = thread.id; + self.thread_connections + .insert(thread_id.clone(), connection_id); let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -261,7 +272,7 @@ impl AnalyticsReducer { ThreadInitializedEvent { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { - thread_id: thread.id, + thread_id: thread_id.clone(), app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), model, @@ -274,6 +285,65 @@ impl AnalyticsReducer { }, }, )); + self.maybe_emit_pending_compactions(&thread_id, out); + } + + fn ingest_compaction(&mut self, input: CodexCompactionEvent, out: &mut Vec) { + let thread_id = input.thread_id.clone(); + if self.try_emit_compaction(input.clone(), out) { + return; + } + self.pending_compactions + .entry(thread_id) + .or_default() + .push(input); + } + + fn maybe_emit_pending_compactions( + &mut self, + thread_id: &str, + out: &mut Vec, + ) { + let Some(pending) = self.pending_compactions.remove(thread_id) else { + return; + }; + let mut still_pending = Vec::new(); + for input in pending { + if !self.try_emit_compaction(input.clone(), out) { + still_pending.push(input); + } + } + if !still_pending.is_empty() { + self.pending_compactions + .insert(thread_id.to_string(), still_pending); + } + } + + fn try_emit_compaction( + &mut self, + input: CodexCompactionEvent, + out: &mut Vec, + ) -> bool { + let connection_metadata = self + .thread_connections + .get(&input.thread_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 { + return false; + }; + out.push(TrackEventRequest::Compaction(Box::new( + CodexCompactionEventRequest { + event_type: "codex_compaction_event", + event_params: codex_compaction_event_params(app_server_client, runtime, input), + }, + ))); + true } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 66f7c020a5..a6c639611a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -50,6 +50,7 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; +use codex_analytics::CompactionTrigger; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::build_track_events_context; @@ -6134,6 +6135,7 @@ pub(crate) async fn run_turn( &sess, &turn_context, InitialContextInjection::BeforeLastUserMessage, + CompactionTrigger::AutoMidTurn, ) .await .is_err() @@ -6316,7 +6318,13 @@ async fn run_pre_sampling_compact( .unwrap_or(i64::MAX); // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { - run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?; + run_auto_compact( + sess, + turn_context, + InitialContextInjection::DoNotInject, + CompactionTrigger::AutoPreTurn, + ) + .await?; pre_sampling_compacted = true; } Ok(pre_sampling_compacted) @@ -6360,6 +6368,7 @@ async fn maybe_run_previous_model_inline_compact( sess, &previous_model_turn_context, InitialContextInjection::DoNotInject, + CompactionTrigger::ModelDownshift, ) .await?; return Ok(true); @@ -6371,12 +6380,14 @@ async fn run_auto_compact( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + trigger: CompactionTrigger, ) -> CodexResult<()> { if should_use_remote_compact_task(&turn_context.provider) { run_inline_remote_auto_compact_task( Arc::clone(sess), Arc::clone(turn_context), initial_context_injection, + trigger, ) .await?; } else { @@ -6384,6 +6395,7 @@ async fn run_auto_compact( Arc::clone(sess), Arc::clone(turn_context), initial_context_injection, + trigger, ) .await?; } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 8300dc650c..01f5925e71 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,4 +1,7 @@ use std::sync::Arc; +use std::time::Instant; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -9,6 +12,11 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::get_last_assistant_message_from_turn; use crate::util::backoff; +use codex_analytics::CodexCompactionEvent; +use codex_analytics::CompactionMode; +use codex_analytics::CompactionStatus; +use codex_analytics::CompactionTrigger; +use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -55,6 +63,7 @@ pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, initial_context_injection: InitialContextInjection, + trigger: CompactionTrigger, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { @@ -63,7 +72,14 @@ pub(crate) async fn run_inline_auto_compact_task( text_elements: Vec::new(), }]; - run_compact_task_inner(sess, turn_context, input, initial_context_injection).await?; + run_compact_task_inner( + sess, + turn_context, + input, + initial_context_injection, + trigger, + ) + .await?; Ok(()) } @@ -84,6 +100,7 @@ pub(crate) async fn run_compact_task( turn_context, input, InitialContextInjection::DoNotInject, + CompactionTrigger::Manual, ) .await } @@ -93,6 +110,39 @@ async fn run_compact_task_inner( turn_context: Arc, input: Vec, initial_context_injection: InitialContextInjection, + trigger: CompactionTrigger, +) -> CodexResult<()> { + let attempt = CompactionAnalyticsAttempt::begin( + sess.as_ref(), + turn_context.as_ref(), + trigger, + CompactionMode::Local, + ) + .await; + let result = run_compact_task_inner_impl( + Arc::clone(&sess), + Arc::clone(&turn_context), + input, + initial_context_injection, + ) + .await; + attempt + .track( + sess.as_ref(), + turn_context.as_ref(), + compaction_status_from_result(&result), + result.as_ref().err().map(ToString::to_string), + /*deleted_items_before_remote_compact*/ None, + ) + .await; + result +} + +async fn run_compact_task_inner_impl( + sess: Arc, + turn_context: Arc, + input: Vec, + initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -233,6 +283,110 @@ async fn run_compact_task_inner( Ok(()) } +pub(crate) struct CompactionAnalyticsAttempt { + enabled: bool, + thread_id: String, + turn_id: String, + trigger: CompactionTrigger, + mode: CompactionMode, + started_at: u64, + start_instant: Instant, + before: CompactionAnalyticsSnapshot, +} + +struct CompactionAnalyticsSnapshot { + input_tokens: Option, + estimated_tokens: Option, + history_items: usize, +} + +impl CompactionAnalyticsAttempt { + pub(crate) async fn begin( + sess: &Session, + turn_context: &TurnContext, + trigger: CompactionTrigger, + mode: CompactionMode, + ) -> Self { + Self { + enabled: sess.enabled(Feature::GeneralAnalytics), + thread_id: sess.conversation_id.to_string(), + turn_id: turn_context.sub_id.clone(), + trigger, + mode, + started_at: now_unix_seconds(), + start_instant: Instant::now(), + before: CompactionAnalyticsSnapshot::capture(sess, turn_context).await, + } + } + + pub(crate) async fn track( + self, + sess: &Session, + turn_context: &TurnContext, + status: CompactionStatus, + error: Option, + deleted_items_before_remote_compact: Option, + ) { + if !self.enabled { + return; + } + let after = CompactionAnalyticsSnapshot::capture(sess, turn_context).await; + sess.services + .analytics_events_client + .track_compaction(CodexCompactionEvent { + thread_id: self.thread_id, + turn_id: self.turn_id, + trigger: self.trigger, + mode: self.mode, + status, + error, + started_at: self.started_at, + completed_at: now_unix_seconds(), + duration_ms: Some( + u64::try_from(self.start_instant.elapsed().as_millis()).unwrap_or(u64::MAX), + ), + input_tokens_before: self.before.input_tokens, + input_tokens_after: after.input_tokens, + estimated_tokens_before: self.before.estimated_tokens, + estimated_tokens_after: after.estimated_tokens, + history_items_before: self.before.history_items, + history_items_after: after.history_items, + deleted_items_before_remote_compact, + }); + } +} + +impl CompactionAnalyticsSnapshot { + async fn capture(sess: &Session, turn_context: &TurnContext) -> Self { + let input_tokens = sess + .total_token_usage() + .await + .map(|usage| usage.input_tokens); + let estimated_tokens = sess.get_estimated_token_count(turn_context).await; + let history_items = sess.clone_history().await.raw_items().len(); + Self { + input_tokens, + estimated_tokens, + history_items, + } + } +} + +pub(crate) fn compaction_status_from_result(result: &CodexResult) -> CompactionStatus { + match result { + Ok(_) => CompactionStatus::Completed, + Err(CodexErr::Interrupted | CodexErr::TurnAborted) => CompactionStatus::Interrupted, + Err(_) => CompactionStatus::Failed, + } +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 5bc7944d28..7d77c6ab07 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -5,12 +5,16 @@ use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::built_tools; +use crate::compact::CompactionAnalyticsAttempt; use crate::compact::InitialContextInjection; +use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::context_manager::is_codex_generated_item; +use codex_analytics::CompactionMode; +use codex_analytics::CompactionTrigger; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -29,8 +33,9 @@ pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, initial_context_injection: InitialContextInjection, + trigger: CompactionTrigger, ) -> CodexResult<()> { - run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection).await?; + run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection, trigger).await?; Ok(()) } @@ -46,17 +51,41 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; - run_remote_compact_task_inner(&sess, &turn_context, InitialContextInjection::DoNotInject).await + run_remote_compact_task_inner( + &sess, + &turn_context, + InitialContextInjection::DoNotInject, + CompactionTrigger::Manual, + ) + .await } async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + trigger: CompactionTrigger, ) -> CodexResult<()> { - if let Err(err) = - run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await - { + let attempt = CompactionAnalyticsAttempt::begin( + sess.as_ref(), + turn_context.as_ref(), + trigger, + CompactionMode::Remote, + ) + .await; + let result = + run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await; + let deleted_items_before_remote_compact = result.as_ref().ok().copied(); + attempt + .track( + sess.as_ref(), + turn_context.as_ref(), + compaction_status_from_result(&result), + result.as_ref().err().map(ToString::to_string), + deleted_items_before_remote_compact, + ) + .await; + if let Err(err) = result { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); @@ -70,7 +99,7 @@ async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, -) -> CodexResult<()> { +) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(turn_context, &compaction_item) .await; @@ -163,7 +192,7 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_completed(turn_context, compaction_item) .await; - Ok(()) + Ok(deleted_items) } pub(crate) async fn process_compacted_history( From fef43c065eb91c73a0d8ff6c577c61f2d558a9c0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 12:37:37 -0700 Subject: [PATCH 217/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client.rs | 17 +++- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/analytics_events.rs | 16 ++++ codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 10 ++- codex-rs/app-server/src/message_processor.rs | 7 +- .../src/message_processor/tracing_tests.rs | 4 + codex-rs/app-server/src/outgoing_message.rs | 86 ++++++++++++++++++- 10 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 codex-rs/app-server/src/analytics_events.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..eb511483a4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -353,7 +353,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -397,7 +397,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..31b994d72e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -222,11 +224,24 @@ impl AnalyticsEventsClient { } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..f1ede6a162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -72,15 +74,22 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..62bf19e99a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -65,17 +65,24 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: _connection_id, request_id: _request_id, request: _request, } => {} - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/analytics_events.rs b/codex-rs/app-server/src/analytics_events.rs new file mode 100644 index 0000000000..24ed12d2ad --- /dev/null +++ b/codex-rs/app-server/src/analytics_events.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 7bddd8a714..7bb8fbda3e 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -50,6 +50,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::analytics_events::analytics_events_client_from_config; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; @@ -355,7 +356,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new_with_analytics( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -380,12 +388,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: Arc::new(EnvironmentManager::from_env()), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a837d9c754..ccb2de59fb 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use crate::analytics_events::analytics_events_client_from_config; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -63,6 +64,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod analytics_events; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; @@ -640,14 +642,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new_with_analytics( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..2e21079530 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -185,6 +185,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -206,6 +207,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, @@ -234,11 +236,6 @@ impl MessageProcessor { }, environment_manager, )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..39b7d7c58f 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use crate::analytics_events::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -237,8 +238,11 @@ fn build_test_processor( let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..b58eecbdb2 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: Option, } #[derive(Clone)] @@ -203,15 +206,35 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { + #[cfg(test)] pub(crate) fn new(sender: mpsc::Sender) -> Self { + Self::new_inner(sender, None) + } + + pub(crate) fn new_with_analytics( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { + Self::new_inner(sender, Some(analytics_events_client)) + } + + fn new_inner( + sender: mpsc::Sender, + analytics_events_client: Option, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + fn analytics_events_client(&self) -> Option<&AnalyticsEventsClient> { + self.analytics_events_client.as_ref() + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +321,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +344,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +390,11 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + && let Some(analytics_events_client) = self.analytics_events_client() + { + analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +652,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +693,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,6 +879,49 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); From c7085f562e359fc28e5604b800f7235d1e3e8535 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 12:37:37 -0700 Subject: [PATCH 218/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..f82ebe6f0a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +100,81 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From 25fa592ae7d2a038614b91dc139f0da6077e945d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 12:37:37 -0700 Subject: [PATCH 219/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 8 +- 6 files changed, 488 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 31b994d72e..c897965115 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -242,6 +243,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..b9482ddb31 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: &CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: &CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index b58eecbdb2..cbebb60549 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -549,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 @@ -573,6 +573,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification.clone()); } } } @@ -583,7 +585,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 @@ -595,6 +597,8 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else if let Some(analytics_events_client) = self.analytics_events_client() { + analytics_events_client.track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 7f2b36773289b35fbf8c943dae1a153eab79cc3f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 13:48:41 -0700 Subject: [PATCH 220/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client.rs | 29 +++++- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/analytics_events.rs | 16 ++++ .../app-server/src/bespoke_event_handling.rs | 26 +++--- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/app-server/src/command_exec.rs | 6 +- codex-rs/app-server/src/fs_watch.rs | 2 +- codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 7 +- .../src/message_processor/tracing_tests.rs | 6 +- codex-rs/app-server/src/outgoing_message.rs | 92 +++++++++++++++++-- codex-rs/app-server/src/thread_status.rs | 12 +-- 15 files changed, 198 insertions(+), 51 deletions(-) create mode 100644 codex-rs/app-server/src/analytics_events.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..eb511483a4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -353,7 +353,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -397,7 +397,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e8ff85584a 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -113,6 +115,18 @@ impl AnalyticsEventsClient { } } + pub fn new_disabled() -> Self { + let (sender, _receiver) = mpsc::channel(1); + Self { + queue: AnalyticsEventsQueue { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + }, + analytics_enabled: Some(false), + } + } + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, @@ -222,11 +236,24 @@ impl AnalyticsEventsClient { } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..f1ede6a162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -72,15 +74,22 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..62bf19e99a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -65,17 +65,24 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: _connection_id, request_id: _request_id, request: _request, } => {} - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/analytics_events.rs b/codex-rs/app-server/src/analytics_events.rs new file mode 100644 index 0000000000..24ed12d2ad --- /dev/null +++ b/codex-rs/app-server/src/analytics_events.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f5c60fe9dc..46c940f5fd 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3155,7 +3155,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3224,7 +3224,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3316,7 +3316,7 @@ mod tests { let thread_state = new_thread_state(); let thread_watch_manager = ThreadWatchManager::new(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3711,7 +3711,7 @@ mod tests { let conversation_id = ThreadId::new(); let event_turn_id = "complete1".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3774,7 +3774,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3821,7 +3821,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3862,7 +3862,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3916,7 +3916,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-123".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4004,7 +4004,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-456".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4075,7 +4075,7 @@ mod tests { let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4328,7 +4328,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4366,7 +4366,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4392,7 +4392,7 @@ mod tests { #[tokio::test] async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let conversation_id = ThreadId::new(); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9282720828..8eb16a55a0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9617,7 +9617,7 @@ mod tests { let connection_id = ConnectionId(7); let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), vec![connection_id], diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index bb086e0dd0..c84bcdef0d 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -757,7 +757,7 @@ mod tests { let manager = CommandExecManager::default(); let err = manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: ConnectionRequestId { connection_id: ConnectionId(1), request_id: codex_app_server_protocol::RequestId::Integer(42), @@ -793,7 +793,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-99".to_string()), exec_request: windows_sandbox_exec_request(), @@ -843,7 +843,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 3a5b226248..41b2d97ce7 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -235,7 +235,7 @@ mod tests { const OUTGOING_BUFFER: usize = 1; let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); FsWatchManager::new_with_file_watcher( - Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(OutgoingMessageSender::new_for_tests(tx)), Arc::new(FileWatcher::noop()), ) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 7bddd8a714..de85158f93 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -50,6 +50,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::analytics_events::analytics_events_client_from_config; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; @@ -355,7 +356,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -380,12 +388,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: Arc::new(EnvironmentManager::from_env()), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a837d9c754..f49efb135e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use crate::analytics_events::analytics_events_client_from_config; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -63,6 +64,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod analytics_events; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; @@ -640,14 +642,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..2e21079530 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -185,6 +185,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -206,6 +207,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, @@ -234,11 +236,6 @@ impl MessageProcessor { }, environment_manager, )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..71ead804a6 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use crate::analytics_events::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -234,11 +235,14 @@ fn build_test_processor( mpsc::Receiver, ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..3306af159c 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: AnalyticsEventsClient, } #[derive(Clone)] @@ -203,15 +206,24 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { - pub(crate) fn new(sender: mpsc::Sender) -> Self { + pub(crate) fn new( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + #[cfg(test)] + pub(crate) fn new_for_tests(sender: mpsc::Sender) -> Self { + Self::new(sender, AnalyticsEventsClient::new_disabled()) + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +310,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +333,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else { + self.analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +379,10 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + { + self.analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +640,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +681,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,10 +867,53 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -876,7 +948,7 @@ mod tests { #[tokio::test] async fn send_response_clears_registered_request_context() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -901,7 +973,7 @@ mod tests { #[tokio::test] async fn send_error_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -939,7 +1011,7 @@ mod tests { #[tokio::test] async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let send_task = tokio::spawn(async move { outgoing .send_server_notification_to_connection_and_wait( @@ -983,7 +1055,7 @@ mod tests { #[tokio::test] async fn connection_closed_clears_registered_request_contexts() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let closed_connection_request = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -1017,7 +1089,7 @@ mod tests { #[tokio::test] async fn notify_client_error_forwards_error_to_waiter() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let (request_id, wait_for_result) = outgoing .send_request(ServerRequestPayload::ApplyPatchApproval( @@ -1051,7 +1123,7 @@ mod tests { #[tokio::test] async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), @@ -1108,7 +1180,7 @@ mod tests { #[tokio::test] async fn cancel_requests_for_thread_cancels_all_thread_requests() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 802f7e197c..a562d53289 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -674,9 +674,9 @@ mod tests { #[tokio::test] async fn status_change_emits_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread(test_thread( @@ -716,9 +716,9 @@ mod tests { #[tokio::test] async fn silent_upsert_skips_initial_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread_silently(test_thread( From 1c3242cdac5c2c4555ef2da0307c8f671510d28e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 13:48:41 -0700 Subject: [PATCH 221/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..f82ebe6f0a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +100,81 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From d3469449d729cb2b637ea8072d3a457a11f88304 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 13:48:41 -0700 Subject: [PATCH 222/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 490 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e8ff85584a..61dadbf29d 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -254,6 +255,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..b9482ddb31 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: &CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: &CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 3306af159c..e06e6d28fe 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 7225f71d9d11c19857e6511804a00d55f9d43e7f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 14:20:14 -0700 Subject: [PATCH 223/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client.rs | 29 +++++- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/analytics_events.rs | 16 ++++ .../app-server/src/bespoke_event_handling.rs | 26 +++--- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/app-server/src/command_exec.rs | 6 +- codex-rs/app-server/src/fs_watch.rs | 2 +- codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 7 +- .../src/message_processor/tracing_tests.rs | 6 +- codex-rs/app-server/src/outgoing_message.rs | 92 +++++++++++++++++-- codex-rs/app-server/src/thread_status.rs | 12 +-- 15 files changed, 198 insertions(+), 51 deletions(-) create mode 100644 codex-rs/app-server/src/analytics_events.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..eb511483a4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -353,7 +353,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -397,7 +397,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e8ff85584a 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -113,6 +115,18 @@ impl AnalyticsEventsClient { } } + pub fn new_disabled() -> Self { + let (sender, _receiver) = mpsc::channel(1); + Self { + queue: AnalyticsEventsQueue { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + }, + analytics_enabled: Some(false), + } + } + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, @@ -222,11 +236,24 @@ impl AnalyticsEventsClient { } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..f1ede6a162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -72,15 +74,22 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..62bf19e99a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -65,17 +65,24 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: _connection_id, request_id: _request_id, request: _request, } => {} - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/analytics_events.rs b/codex-rs/app-server/src/analytics_events.rs new file mode 100644 index 0000000000..24ed12d2ad --- /dev/null +++ b/codex-rs/app-server/src/analytics_events.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 976518465b..d0e925c7d3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3166,7 +3166,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3235,7 +3235,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3327,7 +3327,7 @@ mod tests { let thread_state = new_thread_state(); let thread_watch_manager = ThreadWatchManager::new(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3722,7 +3722,7 @@ mod tests { let conversation_id = ThreadId::new(); let event_turn_id = "complete1".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3785,7 +3785,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3832,7 +3832,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3873,7 +3873,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3927,7 +3927,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-123".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4015,7 +4015,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-456".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4086,7 +4086,7 @@ mod tests { let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4339,7 +4339,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4377,7 +4377,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4403,7 +4403,7 @@ mod tests { #[tokio::test] async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let conversation_id = ThreadId::new(); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index dafaffca3c..a261b608ac 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9638,7 +9638,7 @@ mod tests { let connection_id = ConnectionId(7); let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), vec![connection_id], diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index b72c84e906..7f76cda5c3 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -757,7 +757,7 @@ mod tests { let manager = CommandExecManager::default(); let err = manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: ConnectionRequestId { connection_id: ConnectionId(1), request_id: codex_app_server_protocol::RequestId::Integer(42), @@ -793,7 +793,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-99".to_string()), exec_request: windows_sandbox_exec_request(), @@ -843,7 +843,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 3a5b226248..41b2d97ce7 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -235,7 +235,7 @@ mod tests { const OUTGOING_BUFFER: usize = 1; let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); FsWatchManager::new_with_file_watcher( - Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(OutgoingMessageSender::new_for_tests(tx)), Arc::new(FileWatcher::noop()), ) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 0ae8690096..a865891940 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -50,6 +50,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::analytics_events::analytics_events_client_from_config; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; @@ -357,7 +358,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -382,12 +390,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: args.environment_manager, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a837d9c754..f49efb135e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use crate::analytics_events::analytics_events_client_from_config; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -63,6 +64,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod analytics_events; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; @@ -640,14 +642,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..2e21079530 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -185,6 +185,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -206,6 +207,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, @@ -234,11 +236,6 @@ impl MessageProcessor { }, environment_manager, )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..71ead804a6 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use crate::analytics_events::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -234,11 +235,14 @@ fn build_test_processor( mpsc::Receiver, ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..3306af159c 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: AnalyticsEventsClient, } #[derive(Clone)] @@ -203,15 +206,24 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { - pub(crate) fn new(sender: mpsc::Sender) -> Self { + pub(crate) fn new( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + #[cfg(test)] + pub(crate) fn new_for_tests(sender: mpsc::Sender) -> Self { + Self::new(sender, AnalyticsEventsClient::new_disabled()) + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +310,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +333,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else { + self.analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +379,10 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + { + self.analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +640,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +681,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,10 +867,53 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -876,7 +948,7 @@ mod tests { #[tokio::test] async fn send_response_clears_registered_request_context() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -901,7 +973,7 @@ mod tests { #[tokio::test] async fn send_error_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -939,7 +1011,7 @@ mod tests { #[tokio::test] async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let send_task = tokio::spawn(async move { outgoing .send_server_notification_to_connection_and_wait( @@ -983,7 +1055,7 @@ mod tests { #[tokio::test] async fn connection_closed_clears_registered_request_contexts() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let closed_connection_request = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -1017,7 +1089,7 @@ mod tests { #[tokio::test] async fn notify_client_error_forwards_error_to_waiter() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let (request_id, wait_for_result) = outgoing .send_request(ServerRequestPayload::ApplyPatchApproval( @@ -1051,7 +1123,7 @@ mod tests { #[tokio::test] async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), @@ -1108,7 +1180,7 @@ mod tests { #[tokio::test] async fn cancel_requests_for_thread_cancels_all_thread_requests() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 802f7e197c..a562d53289 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -674,9 +674,9 @@ mod tests { #[tokio::test] async fn status_change_emits_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread(test_thread( @@ -716,9 +716,9 @@ mod tests { #[tokio::test] async fn silent_upsert_skips_initial_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread_silently(test_thread( From 6ac57418f4c72459723cd3545961e4ce6f4e4ff9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 14:20:14 -0700 Subject: [PATCH 224/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..f82ebe6f0a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +100,81 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From 1e42a41242f3f27cb1c8502c213d24fea551d199 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 14:20:14 -0700 Subject: [PATCH 225/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 490 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e8ff85584a..61dadbf29d 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -254,6 +255,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..b9482ddb31 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: &CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: &CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 3306af159c..e06e6d28fe 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From ebc5430333ff4db300d9087eb0fa25c0d836eac1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 17:09:24 -0700 Subject: [PATCH 226/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 490 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d2d75a31ea..46c6ea2890 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -250,6 +251,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..b9482ddb31 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: &CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: &CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From e75ab093acabad48a5d1ef7928729b75721da2bf Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 17:09:24 -0700 Subject: [PATCH 227/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..f82ebe6f0a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +100,81 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From 0b764282f5da20ce65fa02a9f7534edba163231a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 17:09:24 -0700 Subject: [PATCH 228/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client.rs | 43 +++++++-- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/analytics_events.rs | 16 ++++ .../app-server/src/bespoke_event_handling.rs | 26 +++--- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/app-server/src/command_exec.rs | 6 +- codex-rs/app-server/src/fs_watch.rs | 2 +- codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 7 +- .../src/message_processor/tracing_tests.rs | 6 +- codex-rs/app-server/src/outgoing_message.rs | 92 +++++++++++++++++-- codex-rs/app-server/src/thread_status.rs | 12 +-- 15 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 codex-rs/app-server/src/analytics_events.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..eb511483a4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -353,7 +353,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -397,7 +397,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..d2d75a31ea 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -38,8 +40,7 @@ pub(crate) struct AnalyticsEventsQueue { #[derive(Clone)] pub struct AnalyticsEventsClient { - queue: AnalyticsEventsQueue, - analytics_enabled: Option, + queue: Option, } impl AnalyticsEventsQueue { @@ -108,11 +109,15 @@ impl AnalyticsEventsClient { analytics_enabled: Option, ) -> Self { Self { - queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), - analytics_enabled, + queue: (analytics_enabled != Some(false)) + .then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url)), } } + pub fn disabled() -> Self { + Self { queue: None } + } + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, @@ -161,7 +166,10 @@ impl AnalyticsEventsClient { } pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { - if !self.queue.should_enqueue_app_used(&tracking, &app) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_app_used(&tracking, &app) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed( @@ -170,7 +178,10 @@ impl AnalyticsEventsClient { } pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { - if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_plugin_used(&tracking, &plugin) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed( @@ -215,18 +226,30 @@ impl AnalyticsEventsClient { } pub(crate) fn record_fact(&self, input: AnalyticsFact) { - if self.analytics_enabled == Some(false) { - return; + if let Some(queue) = self.queue.as_ref() { + queue.try_send(input); } - self.queue.try_send(input); } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..f1ede6a162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -72,15 +74,22 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..62bf19e99a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -65,17 +65,24 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: _connection_id, request_id: _request_id, request: _request, } => {} - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/analytics_events.rs b/codex-rs/app-server/src/analytics_events.rs new file mode 100644 index 0000000000..24ed12d2ad --- /dev/null +++ b/codex-rs/app-server/src/analytics_events.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 976518465b..d0e925c7d3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3166,7 +3166,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3235,7 +3235,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3327,7 +3327,7 @@ mod tests { let thread_state = new_thread_state(); let thread_watch_manager = ThreadWatchManager::new(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3722,7 +3722,7 @@ mod tests { let conversation_id = ThreadId::new(); let event_turn_id = "complete1".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3785,7 +3785,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3832,7 +3832,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3873,7 +3873,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3927,7 +3927,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-123".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4015,7 +4015,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-456".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4086,7 +4086,7 @@ mod tests { let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4339,7 +4339,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4377,7 +4377,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4403,7 +4403,7 @@ mod tests { #[tokio::test] async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let conversation_id = ThreadId::new(); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index dafaffca3c..a261b608ac 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9638,7 +9638,7 @@ mod tests { let connection_id = ConnectionId(7); let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), vec![connection_id], diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index b72c84e906..7f76cda5c3 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -757,7 +757,7 @@ mod tests { let manager = CommandExecManager::default(); let err = manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: ConnectionRequestId { connection_id: ConnectionId(1), request_id: codex_app_server_protocol::RequestId::Integer(42), @@ -793,7 +793,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-99".to_string()), exec_request: windows_sandbox_exec_request(), @@ -843,7 +843,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 3a5b226248..41b2d97ce7 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -235,7 +235,7 @@ mod tests { const OUTGOING_BUFFER: usize = 1; let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); FsWatchManager::new_with_file_watcher( - Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(OutgoingMessageSender::new_for_tests(tx)), Arc::new(FileWatcher::noop()), ) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 0ae8690096..a865891940 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -50,6 +50,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::analytics_events::analytics_events_client_from_config; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; @@ -357,7 +358,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -382,12 +390,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: args.environment_manager, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a837d9c754..f49efb135e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use crate::analytics_events::analytics_events_client_from_config; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -63,6 +64,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod analytics_events; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; @@ -640,14 +642,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..2e21079530 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -185,6 +185,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -206,6 +207,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, @@ -234,11 +236,6 @@ impl MessageProcessor { }, environment_manager, )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..71ead804a6 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use crate::analytics_events::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -234,11 +235,14 @@ fn build_test_processor( mpsc::Receiver, ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..d31d608054 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: AnalyticsEventsClient, } #[derive(Clone)] @@ -203,15 +206,24 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { - pub(crate) fn new(sender: mpsc::Sender) -> Self { + pub(crate) fn new( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + #[cfg(test)] + pub(crate) fn new_for_tests(sender: mpsc::Sender) -> Self { + Self::new(sender, AnalyticsEventsClient::disabled()) + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +310,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +333,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else { + self.analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +379,10 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + { + self.analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +640,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +681,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,10 +867,53 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -876,7 +948,7 @@ mod tests { #[tokio::test] async fn send_response_clears_registered_request_context() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -901,7 +973,7 @@ mod tests { #[tokio::test] async fn send_error_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -939,7 +1011,7 @@ mod tests { #[tokio::test] async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let send_task = tokio::spawn(async move { outgoing .send_server_notification_to_connection_and_wait( @@ -983,7 +1055,7 @@ mod tests { #[tokio::test] async fn connection_closed_clears_registered_request_contexts() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let closed_connection_request = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -1017,7 +1089,7 @@ mod tests { #[tokio::test] async fn notify_client_error_forwards_error_to_waiter() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let (request_id, wait_for_result) = outgoing .send_request(ServerRequestPayload::ApplyPatchApproval( @@ -1051,7 +1123,7 @@ mod tests { #[tokio::test] async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), @@ -1108,7 +1180,7 @@ mod tests { #[tokio::test] async fn cancel_requests_for_thread_cancels_all_thread_requests() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 802f7e197c..a562d53289 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -674,9 +674,9 @@ mod tests { #[tokio::test] async fn status_change_emits_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread(test_thread( @@ -716,9 +716,9 @@ mod tests { #[tokio::test] async fn silent_upsert_skips_initial_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread_silently(test_thread( From 8580aad02200648d67f8422e34a8810aafcdacbb Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 17:47:22 -0700 Subject: [PATCH 229/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 490 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d2d75a31ea..46c6ea2890 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -250,6 +251,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 f82ebe6f0a..11ca3ae290 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -113,6 +113,7 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, GuardianApproved, @@ -127,6 +128,7 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, @@ -136,6 +138,7 @@ pub(crate) enum ToolCallTerminalStatus { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] +#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..ea283a91fb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(*source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(*source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 6502f7b6755cee97443e930fea51135f47382b2d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 20:29:30 -0700 Subject: [PATCH 230/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 5 files changed, 487 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d2d75a31ea..46c6ea2890 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -250,6 +251,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + notification: Box::new(notification), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..ea283a91fb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(*source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(*source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From b44084010d40417cec99104ec0fda672d57b70b6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 20:29:28 -0700 Subject: [PATCH 231/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 81 +++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..e8d207a5d4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,8 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + #[allow(dead_code)] + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +101,85 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From 9ba89ef17216ed7c6573e748a9de1937d8a83e45 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 20:56:25 -0700 Subject: [PATCH 232/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 17 +- codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 499 insertions(+), 9 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d2d75a31ea..46c6ea2890 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -250,6 +251,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 e8d207a5d4..8ff1b4b518 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,7 +37,6 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), - #[allow(dead_code)] ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), @@ -103,7 +102,6 @@ pub(crate) struct ThreadInitializedEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolKind { Shell, UnifiedExec, @@ -115,37 +113,46 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, + #[allow(dead_code)] GuardianApproved, + #[allow(dead_code)] GuardianDenied, + #[allow(dead_code)] GuardianAborted, + #[allow(dead_code)] UserApproved, + #[allow(dead_code)] UserApprovedForSession, + #[allow(dead_code)] UserDenied, + #[allow(dead_code)] UserAborted, + #[allow(dead_code)] ConfigAllowed, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, Rejected, + #[allow(dead_code)] Interrupted, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, + #[allow(dead_code)] ApprovalAborted, + #[allow(dead_code)] SandboxDenied, + #[allow(dead_code)] PolicyForbidden, } diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..ea283a91fb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(*source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(*source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 68d22f33479dae91d5ac8283b895b941e249e260 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 21:03:26 -0700 Subject: [PATCH 233/383] [codex-analytics] add tool call event schema --- .../analytics/src/analytics_client_tests.rs | 88 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 81 +++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..39b5e0fa88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -346,6 +351,89 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn tool_call_event_serializes_expected_shape() { + let event = TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + tool_call_id: "tool-call-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(), + }, + tool_name: "shell".to_string(), + tool_kind: ToolKind::Shell, + started_at: 123, + completed_at: Some(125), + duration_ms: Some(2000), + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: Some(0), + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize tool call event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_tool_call_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "tool_call_id": "tool-call-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" + }, + "tool_name": "shell", + "tool_kind": "shell", + "started_at": 123, + "completed_at": 125, + "duration_ms": 2000, + "execution_started": true, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_review_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "exit_code": 0, + "requested_additional_permissions": false, + "requested_network_access": false, + "retry_count": 0 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..e8d207a5d4 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,8 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + #[allow(dead_code)] + ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +101,85 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, + ConfigAllowed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) tool_call_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) tool_name: String, + pub(crate) tool_kind: ToolKind, + pub(crate) started_at: u64, + pub(crate) completed_at: 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_review_outcome: ToolCallFinalReviewOutcome, + pub(crate) terminal_status: ToolCallTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) exit_code: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, + pub(crate) retry_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexToolCallEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, From c01eb26e9444287ab9c0294f9b2427f9eb0950ec Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 21:03:26 -0700 Subject: [PATCH 234/383] [codex-analytics] emit basic tool events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 119 ++++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 17 +- codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 349 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 499 insertions(+), 9 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 39b5e0fa88..217b5c9f4b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -39,12 +39,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +125,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "tool-call-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -537,6 +587,75 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_basic_tool_call_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "tool event should emit on completion"); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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_tool_call_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"]["tool_call_id"], "tool-call-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!(payload[0]["event_params"]["tool_kind"], "shell"); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + 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"]["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" + ); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d2d75a31ea..46c6ea2890 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -250,6 +251,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 e8d207a5d4..8ff1b4b518 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,7 +37,6 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), - #[allow(dead_code)] ToolCall(CodexToolCallEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), @@ -103,7 +102,6 @@ pub(crate) struct ThreadInitializedEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolKind { Shell, UnifiedExec, @@ -115,37 +113,46 @@ pub(crate) enum ToolKind { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolCallFinalReviewOutcome { NotNeeded, + #[allow(dead_code)] GuardianApproved, + #[allow(dead_code)] GuardianDenied, + #[allow(dead_code)] GuardianAborted, + #[allow(dead_code)] UserApproved, + #[allow(dead_code)] UserApprovedForSession, + #[allow(dead_code)] UserDenied, + #[allow(dead_code)] UserAborted, + #[allow(dead_code)] ConfigAllowed, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolCallTerminalStatus { Completed, Failed, Rejected, + #[allow(dead_code)] Interrupted, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolCallFailureKind { ToolError, ApprovalDenied, + #[allow(dead_code)] ApprovalAborted, + #[allow(dead_code)] SandboxDenied, + #[allow(dead_code)] PolicyForbidden, } diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..ea283a91fb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,11 +5,17 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolCallEventParams; +use crate::events::CodexToolCallEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolCallFailureKind; +use crate::events::ToolCallFinalReviewOutcome; +use crate::events::ToolCallTerminalStatus; +use crate::events::ToolKind; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; @@ -27,7 +33,16 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -36,10 +51,13 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + tool_calls: HashMap, } struct ConnectionState { @@ -47,6 +65,21 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolCallState { + connection_id: u64, + started_at: u64, +} + +struct ToolCallCompletedMetadata { + tool_call_id: String, + tool_name: String, + tool_kind: ToolKind, + terminal_status: ToolCallTerminalStatus, + failure_kind: Option, + exit_code: Option, + duration_ms: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +116,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -282,6 +320,315 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(tool_call_id) = tool_call_id(¬ification.item) { + self.tool_calls + .entry(tool_call_id.to_string()) + .or_insert_with(|| ToolCallState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(completed_metadata) = tool_call_completed_metadata(¬ification.item) + else { + return; + }; + let Some(started) = self.tool_calls.remove(&completed_metadata.tool_call_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + let duration_ms = completed_metadata.duration_ms.or_else(|| { + completed_at + .checked_sub(started.started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }); + + out.push(TrackEventRequest::ToolCall(CodexToolCallEventRequest { + event_type: "codex_tool_call_event", + event_params: CodexToolCallEventParams { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + tool_call_id: completed_metadata.tool_call_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + tool_name: completed_metadata.tool_name, + tool_kind: completed_metadata.tool_kind, + started_at: started.started_at, + completed_at: Some(completed_at), + duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_review_outcome: ToolCallFinalReviewOutcome::NotNeeded, + terminal_status: completed_metadata.terminal_status, + failure_kind: completed_metadata.failure_kind, + exit_code: completed_metadata.exit_code, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + }, + })); + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn tool_call_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_call_completed_metadata(item: &ThreadItem) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let tool_kind = command_execution_tool_kind(*source); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: command_execution_tool_name(*source).to_string(), + tool_kind, + terminal_status, + failure_kind, + exit_code: *exit_code, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::FileChange { id, status, .. } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "apply_patch".to_string(), + tool_kind: ToolKind::ApplyPatch, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::McpToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Mcp, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: tool.clone(), + tool_kind: ToolKind::Dynamic, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: option_i64_to_u64(*duration_ms), + }) + } + ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: collab_agent_tool_name(tool).to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + ThreadItem::WebSearch { id, .. } => Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "web_search".to_string(), + tool_kind: ToolKind::Other, + terminal_status: ToolCallTerminalStatus::Completed, + failure_kind: None, + exit_code: None, + duration_ms: None, + }), + ThreadItem::ImageGeneration { id, status, .. } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + Some(ToolCallCompletedMetadata { + tool_call_id: id.clone(), + tool_name: "image_generation".to_string(), + tool_kind: ToolKind::Other, + terminal_status, + failure_kind, + exit_code: None, + duration_ms: None, + }) + } + _ => None, + } +} + +fn command_execution_tool_kind(source: CommandExecutionSource) -> ToolKind { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => ToolKind::UnifiedExec, + CommandExecutionSource::Agent | CommandExecutionSource::UserShell => ToolKind::Shell, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolCallTerminalStatus::Rejected, + Some(ToolCallFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolCallTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolCallTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolCallTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolCallTerminalStatus::Failed, + Some(ToolCallFailureKind::ToolError), + ), + _ => (ToolCallTerminalStatus::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", + } +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From c852a44669b7c35195c720c1a29763712802052c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 21:03:26 -0700 Subject: [PATCH 235/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client.rs | 43 +++++++-- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/analytics_events.rs | 16 ++++ .../app-server/src/bespoke_event_handling.rs | 26 +++--- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/app-server/src/command_exec.rs | 6 +- codex-rs/app-server/src/fs_watch.rs | 2 +- codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 7 +- .../src/message_processor/tracing_tests.rs | 6 +- codex-rs/app-server/src/outgoing_message.rs | 92 +++++++++++++++++-- codex-rs/app-server/src/thread_status.rs | 12 +-- 15 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 codex-rs/app-server/src/analytics_events.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..eb511483a4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -353,7 +353,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -397,7 +397,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..d2d75a31ea 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,8 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -38,8 +40,7 @@ pub(crate) struct AnalyticsEventsQueue { #[derive(Clone)] pub struct AnalyticsEventsClient { - queue: AnalyticsEventsQueue, - analytics_enabled: Option, + queue: Option, } impl AnalyticsEventsQueue { @@ -108,11 +109,15 @@ impl AnalyticsEventsClient { analytics_enabled: Option, ) -> Self { Self { - queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), - analytics_enabled, + queue: (analytics_enabled != Some(false)) + .then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url)), } } + pub fn disabled() -> Self { + Self { queue: None } + } + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, @@ -161,7 +166,10 @@ impl AnalyticsEventsClient { } pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { - if !self.queue.should_enqueue_app_used(&tracking, &app) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_app_used(&tracking, &app) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed( @@ -170,7 +178,10 @@ impl AnalyticsEventsClient { } pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { - if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_plugin_used(&tracking, &plugin) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed( @@ -215,18 +226,30 @@ impl AnalyticsEventsClient { } pub(crate) fn record_fact(&self, input: AnalyticsFact) { - if self.analytics_enabled == Some(false) { - return; + if let Some(queue) = self.queue.as_ref() { + queue.try_send(input); } - self.queue.try_send(input); } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); } + + pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { + self.record_fact(AnalyticsFact::ServerRequest { + connection_id, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..f1ede6a162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -5,6 +5,8 @@ use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -72,15 +74,22 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..62bf19e99a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -65,17 +65,24 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: _connection_id, request_id: _request_id, request: _request, } => {} - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Notification(_notification) => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { diff --git a/codex-rs/app-server/src/analytics_events.rs b/codex-rs/app-server/src/analytics_events.rs new file mode 100644 index 0000000000..24ed12d2ad --- /dev/null +++ b/codex-rs/app-server/src/analytics_events.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a6a5951be8..101d909c15 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3169,7 +3169,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3238,7 +3238,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3330,7 +3330,7 @@ mod tests { let thread_state = new_thread_state(); let thread_watch_manager = ThreadWatchManager::new(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3725,7 +3725,7 @@ mod tests { let conversation_id = ThreadId::new(); let event_turn_id = "complete1".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3788,7 +3788,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3835,7 +3835,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3876,7 +3876,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3930,7 +3930,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-123".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4018,7 +4018,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-456".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4089,7 +4089,7 @@ mod tests { let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4342,7 +4342,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4380,7 +4380,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4406,7 +4406,7 @@ mod tests { #[tokio::test] async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let conversation_id = ThreadId::new(); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4e3bc70cb3..d7c7b25dcb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9661,7 +9661,7 @@ mod tests { let connection_id = ConnectionId(7); let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), vec![connection_id], diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index b72c84e906..7f76cda5c3 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -757,7 +757,7 @@ mod tests { let manager = CommandExecManager::default(); let err = manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: ConnectionRequestId { connection_id: ConnectionId(1), request_id: codex_app_server_protocol::RequestId::Integer(42), @@ -793,7 +793,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-99".to_string()), exec_request: windows_sandbox_exec_request(), @@ -843,7 +843,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 3a5b226248..41b2d97ce7 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -235,7 +235,7 @@ mod tests { const OUTGOING_BUFFER: usize = 1; let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); FsWatchManager::new_with_file_watcher( - Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(OutgoingMessageSender::new_for_tests(tx)), Arc::new(FileWatcher::noop()), ) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 0ae8690096..a865891940 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -50,6 +50,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::analytics_events::analytics_events_client_from_config; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; @@ -357,7 +358,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -382,12 +390,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: args.environment_manager, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a837d9c754..f49efb135e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use crate::analytics_events::analytics_events_client_from_config; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -63,6 +64,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod analytics_events; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; @@ -640,14 +642,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..2e21079530 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -185,6 +185,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -206,6 +207,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, @@ -234,11 +236,6 @@ impl MessageProcessor { }, environment_manager, )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index ef88364a3a..71ead804a6 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use crate::analytics_events::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -234,11 +235,14 @@ fn build_test_processor( mpsc::Receiver, ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..d31d608054 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: AnalyticsEventsClient, } #[derive(Clone)] @@ -203,15 +206,24 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { - pub(crate) fn new(sender: mpsc::Sender) -> Self { + pub(crate) fn new( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + #[cfg(test)] + pub(crate) fn new_for_tests(sender: mpsc::Sender) -> Self { + Self::new(sender, AnalyticsEventsClient::disabled()) + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +310,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +333,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else { + self.analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +379,10 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + { + self.analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +640,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +681,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,10 +867,53 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -876,7 +948,7 @@ mod tests { #[tokio::test] async fn send_response_clears_registered_request_context() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -901,7 +973,7 @@ mod tests { #[tokio::test] async fn send_error_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -939,7 +1011,7 @@ mod tests { #[tokio::test] async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let send_task = tokio::spawn(async move { outgoing .send_server_notification_to_connection_and_wait( @@ -983,7 +1055,7 @@ mod tests { #[tokio::test] async fn connection_closed_clears_registered_request_contexts() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let closed_connection_request = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -1017,7 +1089,7 @@ mod tests { #[tokio::test] async fn notify_client_error_forwards_error_to_waiter() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let (request_id, wait_for_result) = outgoing .send_request(ServerRequestPayload::ApplyPatchApproval( @@ -1051,7 +1123,7 @@ mod tests { #[tokio::test] async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), @@ -1108,7 +1180,7 @@ mod tests { #[tokio::test] async fn cancel_requests_for_thread_cancels_all_thread_requests() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 802f7e197c..a562d53289 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -674,9 +674,9 @@ mod tests { #[tokio::test] async fn status_change_emits_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread(test_thread( @@ -716,9 +716,9 @@ mod tests { #[tokio::test] async fn silent_upsert_skips_initial_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread_silently(test_thread( From 399c271f620023502ce8ff887b82dc5b53354358 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:00:57 -0700 Subject: [PATCH 236/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 504 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 52 ++ codex-rs/analytics/src/facts.rs | 48 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 395 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++ codex-rs/core/src/codex.rs | 73 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1416 insertions(+), 45 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..18b0bba1b0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,44 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +131,182 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1016,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..91c32b769b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,54 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..e15d10406d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,44 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +136,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..3acf8198a1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,8 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..d2754d8259 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,161 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a6a5951be8..d232c44089 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1731,6 +1738,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1908,6 +1916,7 @@ 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 { @@ -1922,6 +1931,10 @@ 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; @@ -2114,6 +2127,7 @@ 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>, ) { @@ -2134,6 +2148,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2143,6 +2158,7 @@ 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>, ) { @@ -2158,6 +2174,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2897,6 +2914,7 @@ 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; @@ -3018,6 +3036,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3032,6 +3051,7 @@ 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(), @@ -3343,6 +3363,13 @@ 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(), }; @@ -3751,6 +3778,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3799,6 +3827,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3846,6 +3875,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4112,6 +4142,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4133,6 +4164,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4144,6 +4176,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9cb8403f13..3582a4dc37 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6687,6 +6687,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7491,6 +7500,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 8f276e331b..1d509d8bf2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +244,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 01d7e068c6..8f54977956 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,8 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -187,6 +189,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -436,6 +439,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -490,6 +494,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -680,6 +685,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1608,6 +1614,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -2006,11 +2013,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2333,6 +2342,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6166,6 +6180,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6467,6 +6483,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3d74773ba7..a19c44a6eb 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6eb66a5198..25d0a5613e 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2760,6 +2760,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 30879fc82a..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -454,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index eaa9f8e2b4..a3b11d71b8 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -521,6 +521,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a2a110b2df..7811654a2e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1103,6 +1103,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f1bbc0a7ff..80d1535fc2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2416,6 +2416,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From fc992fe268fe6b0ec16ecdb57d10fab2de66cff2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:00:57 -0700 Subject: [PATCH 237/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 18b0bba1b0..9414ba14c3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -67,6 +68,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -182,6 +184,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -236,6 +252,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -305,6 +322,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1055,6 +1083,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1111,6 +1144,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1133,6 +1171,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1182,6 +1221,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1195,6 +1242,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1267,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1246,6 +1295,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1277,6 +1327,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1308,6 +1359,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1325,6 +1377,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 91c32b769b..4529d80477 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -163,6 +163,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e15d10406d..58bb593ccc 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -69,6 +70,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -137,6 +145,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 3acf8198a1..bcd5216d20 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -12,6 +12,7 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d2754d8259..a62f2e94c4 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -544,6 +574,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -581,6 +612,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 1d509d8bf2..ae65e1030c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -310,6 +310,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From f751c655acfe280021d40a6309b42a52bc08c866 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:00:57 -0700 Subject: [PATCH 238/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 583 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 210 ++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/codex.rs | 1 - 10 files changed, 994 insertions(+), 85 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 9414ba14c3..52860de1d4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,10 +16,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,121 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +378,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1074,7 +1174,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1135,7 +1235,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1160,6 +1260,333 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: non_steerable_review_error(), + error_type: Some(non_steerable_review_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: input_too_large_steer_error(), + error_type: Some(input_too_large_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1218,6 +1645,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1231,6 +1659,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4529d80477..8cfeb7a354 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -179,6 +183,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -270,6 +293,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 58bb593ccc..02366f7028 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -85,6 +87,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -136,6 +185,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index bcd5216d20..4fdec9f7bf 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,13 +5,19 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a62f2e94c4..77943b0aeb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,27 +17,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +63,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +80,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +88,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +111,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +145,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +226,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +267,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +289,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +442,46 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + let RequestState::TurnSteer(pending_request) = request else { + return; + }; + self.emit_turn_steer_event( + connection_id, + pending_request, + /*accepted_turn_id*/ None, + TurnSteerResult::Rejected, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +497,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +516,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +572,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +739,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +808,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3582a4dc37..986d422aa3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -503,6 +506,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6587,12 +6606,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6604,6 +6629,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6704,6 +6730,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6728,23 +6755,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6765,19 +6800,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6790,11 +6840,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6807,12 +6855,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6820,6 +6878,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f8ae10764..2728ca1de0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..5dc148b90a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8f54977956..08122a1122 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4190,7 +4190,6 @@ impl Session { turn_kind: NonSteerableTurnKind::Compact, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } if let Some(responsesapi_client_metadata) = responsesapi_client_metadata From 3eb168c1d8b1ee41e1b3c8f108a19adac5f1519d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:04:40 -0700 Subject: [PATCH 239/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 12 ++++++- codex-rs/analytics/src/events.rs | 34 ++++++++++++++----- codex-rs/analytics/src/facts.rs | 8 +++++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 3 +- .../app-server/tests/suite/v2/turn_start.rs | 13 ++++++- codex-rs/core/src/codex.rs | 14 ++++++++ codex-rs/core/src/codex_tests.rs | 7 ++++ codex-rs/core/src/codex_thread.rs | 2 ++ 9 files changed, 82 insertions(+), 12 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 52860de1d4..a066ce4ac0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,6 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -29,6 +28,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -254,6 +254,16 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + fn sample_runtime_metadata() -> CodexRuntimeMetadata { CodexRuntimeMetadata { codex_rs_version: "0.1.0".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8cfeb7a354..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -134,7 +127,6 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, pub(crate) submission_type: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -375,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 02366f7028..c4dc7cc8e7 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -79,6 +79,14 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 4fdec9f7bf..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -12,6 +12,7 @@ pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 77943b0aeb..785920607b 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; 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 ae65e1030c..3df36cb68d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -299,12 +299,23 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 08122a1122..c551bc816e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -659,6 +659,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1095,6 +1096,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1176,6 +1185,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1200,6 +1210,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1621,6 +1632,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -4190,6 +4203,7 @@ impl Session { turn_kind: NonSteerableTurnKind::Compact, }); } + None => return Err(SteerInputError::NoActiveTurn(input)), } if let Some(responsesapi_client_metadata) = responsesapi_client_metadata diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 25d0a5613e..87d3a45804 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -2012,6 +2013,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2114,6 +2116,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2463,6 +2466,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2726,6 +2730,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2830,6 +2835,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3675,6 +3681,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f95c615a67..60ff23a6b8 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 714613c189905599208c2c57aa235ef5d81df721 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:27:48 -0700 Subject: [PATCH 240/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 583 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 210 ++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/codex.rs | 1 - 10 files changed, 994 insertions(+), 85 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 9414ba14c3..52860de1d4 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,10 +16,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,121 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +378,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1074,7 +1174,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1135,7 +1235,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1160,6 +1260,333 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: non_steerable_review_error(), + error_type: Some(non_steerable_review_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: input_too_large_steer_error(), + error_type: Some(input_too_large_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1218,6 +1645,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1231,6 +1659,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4529d80477..8cfeb7a354 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,12 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -41,6 +44,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -179,6 +183,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -270,6 +293,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 58bb593ccc..02366f7028 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -85,6 +87,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -136,6 +185,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index bcd5216d20..4fdec9f7bf 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,13 +5,19 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a62f2e94c4..77943b0aeb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -16,27 +17,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +63,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +80,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +88,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +111,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +145,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +226,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +267,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +289,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +442,46 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + let RequestState::TurnSteer(pending_request) = request else { + return; + }; + self.emit_turn_steer_event( + connection_id, + pending_request, + /*accepted_turn_id*/ None, + TurnSteerResult::Rejected, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +497,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +516,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +572,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +739,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +808,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7f1b535353..999912b1cf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -497,6 +500,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6566,12 +6585,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6583,6 +6608,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6682,6 +6708,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6706,23 +6733,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6739,19 +6774,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6764,11 +6814,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6781,12 +6829,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6794,6 +6852,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f8ae10764..2728ca1de0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1f1432cf0c..c44a57375d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4090,7 +4090,6 @@ impl Session { turn_kind: NonSteerableTurnKind::Compact, }); } - None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; From dbb736c5e1f7887f411b48b530340e0679a4e478 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:27:48 -0700 Subject: [PATCH 241/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 504 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 52 ++ codex-rs/analytics/src/facts.rs | 48 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 395 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++ codex-rs/core/src/codex.rs | 73 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1416 insertions(+), 45 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..18b0bba1b0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,6 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -27,28 +28,44 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +131,182 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1016,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..91c32b769b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,9 @@ use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -37,6 +40,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +126,54 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..e15d10406d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,44 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +136,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..3acf8198a1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -10,6 +10,8 @@ pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..d2754d8259 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -26,11 +28,22 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,161 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 976518465b..b21a0f0ee0 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1731,6 +1738,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1908,6 +1916,7 @@ 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 { @@ -1922,6 +1931,10 @@ 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; @@ -2114,6 +2127,7 @@ 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>, ) { @@ -2134,6 +2148,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2143,6 +2158,7 @@ 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>, ) { @@ -2158,6 +2174,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2897,6 +2914,7 @@ 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; @@ -3018,6 +3036,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3032,6 +3051,7 @@ 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(), @@ -3340,6 +3360,13 @@ 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(), }; @@ -3748,6 +3775,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3796,6 +3824,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3843,6 +3872,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4109,6 +4139,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4130,6 +4161,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4141,6 +4173,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1e10a5696d..7f1b535353 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6665,6 +6665,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7449,6 +7458,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 59591dd0d1..4c6fc90bc0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +244,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 66f7c020a5..1f1432cf0c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,8 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +188,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -428,6 +431,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -482,6 +486,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -672,6 +677,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1521,6 +1527,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1925,11 +1932,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2244,6 +2253,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6012,6 +6026,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6298,6 +6314,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index aef0a92d76..9aea3e5594 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index d57d92c1ec..f0bded7187 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index ad62db4d79..ba75498f75 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -519,6 +519,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 5200d252c4..3add0f3ea7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1099,6 +1099,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6912243ab2..6bad6a3511 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2279,6 +2279,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 5a701c08c157c88939524cc5b5a26faecd00e292 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:27:48 -0700 Subject: [PATCH 242/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 12 ++++++- codex-rs/analytics/src/events.rs | 34 ++++++++++++++----- codex-rs/analytics/src/facts.rs | 8 +++++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 3 +- .../app-server/tests/suite/v2/turn_start.rs | 13 ++++++- codex-rs/core/src/codex.rs | 14 ++++++++ codex-rs/core/src/codex_tests.rs | 7 ++++ codex-rs/core/src/codex_thread.rs | 2 ++ 9 files changed, 82 insertions(+), 12 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 52860de1d4..a066ce4ac0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,6 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -29,6 +28,7 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; @@ -254,6 +254,16 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + fn sample_runtime_metadata() -> CodexRuntimeMetadata { CodexRuntimeMetadata { codex_rs_version: "0.1.0".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8cfeb7a354..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -23,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -134,7 +127,6 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, pub(crate) submission_type: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -375,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 02366f7028..c4dc7cc8e7 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -79,6 +79,14 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 4fdec9f7bf..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -12,6 +12,7 @@ pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 77943b0aeb..785920607b 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -10,7 +10,6 @@ use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -21,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; 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 3c7664656d..cb1fcd14fc 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -299,12 +299,23 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c44a57375d..11480fcb43 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -651,6 +651,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1076,6 +1077,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1137,6 +1146,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1161,6 +1171,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1534,6 +1545,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -4090,6 +4103,7 @@ impl Session { turn_kind: NonSteerableTurnKind::Compact, }); } + None => return Err(SteerInputError::NoActiveTurn(input)), } let mut turn_state = active_turn.turn_state.lock().await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f0bded7187..615ca14e62 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1872,6 +1873,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1974,6 +1976,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2323,6 +2326,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2586,6 +2590,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3531,6 +3537,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 2f9a1ff0f1238a997004b6b94aefd4a9faa4fbdb Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 13:27:48 -0700 Subject: [PATCH 243/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 18b0bba1b0..9414ba14c3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -67,6 +68,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -182,6 +184,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -236,6 +252,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -305,6 +322,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1055,6 +1083,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1111,6 +1144,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1133,6 +1171,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1182,6 +1221,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1195,6 +1242,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1219,6 +1267,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1246,6 +1295,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1277,6 +1327,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1308,6 +1359,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1325,6 +1377,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 91c32b769b..4529d80477 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -163,6 +163,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e15d10406d..58bb593ccc 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -69,6 +70,13 @@ pub struct TurnResolvedConfigFact { pub is_first_turn: bool, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -137,6 +145,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 3acf8198a1..bcd5216d20 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -12,6 +12,7 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d2754d8259..a62f2e94c4 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -544,6 +574,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -581,6 +612,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 4c6fc90bc0..3c7664656d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -310,6 +310,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From f695c726dfef2f57c5c482603a53797e146282d3 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 14:07:13 -0700 Subject: [PATCH 244/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 601 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 51 +- codex-rs/analytics/src/facts.rs | 63 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 243 ++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 1049 insertions(+), 97 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 9414ba14c3..5b9ed89bf7 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,6 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -16,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -27,9 +28,11 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,130 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +387,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1051,6 +1160,7 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), app_server_client: sample_app_server_client_metadata(), runtime: sample_runtime_metadata(), submission_type: None, @@ -1074,7 +1184,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1101,6 +1211,7 @@ fn turn_event_serializes_expected_shape() { "event_params": { "thread_id": "thread-2", "turn_id": "turn-2", + "product_client_id": "codex-tui", "submission_type": null, "app_server_client": { "product_client_id": "codex-tui", @@ -1135,7 +1246,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1160,6 +1271,333 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: non_steerable_review_error(), + error_type: Some(non_steerable_review_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: input_too_large_steer_error(), + error_type: Some(input_too_large_error_type()), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1210,7 +1648,10 @@ async fn turn_lifecycle_emits_turn_event() { "runtime_arch": "aarch64", }) ); - assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); assert_eq!(payload["event_params"]["ephemeral"], json!(false)); assert_eq!(payload["event_params"]["thread_source"], json!("user")); assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); @@ -1218,6 +1659,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1231,6 +1673,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4529d80477..79e4431648 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,9 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -20,14 +24,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -41,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -179,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -270,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 58bb593ccc..c4dc7cc8e7 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -77,6 +79,14 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -85,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -136,6 +193,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index bcd5216d20..4fdec9f7bf 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,13 +5,19 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a62f2e94c4..7065d0ad54 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,36 +7,47 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -578,14 +740,15 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, + product_client_id: app_server_client.product_client_id.clone(), app_server_client, runtime, submission_type, ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: turn_subagent_source_name(&session_source), - parent_thread_id: turn_parent_thread_id(&session_source), + subagent_source: None, + parent_thread_id: None, model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), @@ -603,7 +766,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +835,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7f1b535353..999912b1cf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -497,6 +500,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6566,12 +6585,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6583,6 +6608,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6682,6 +6708,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6706,23 +6733,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6739,19 +6774,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6764,11 +6814,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6781,12 +6829,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6794,6 +6852,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f8ae10764..2728ca1de0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 3dab8506dc5f382fb93e5e040b12d7cc0b9f64f0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 14:07:24 -0700 Subject: [PATCH 245/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 25 ++++++++----------- codex-rs/analytics/src/events.rs | 25 ++++++++++++++++++- codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 7 +++--- .../app-server/tests/suite/v2/turn_start.rs | 13 +++++++++- codex-rs/core/src/codex.rs | 13 ++++++++++ codex-rs/core/src/codex_tests.rs | 7 ++++++ codex-rs/core/src/codex_thread.rs | 2 ++ 8 files changed, 73 insertions(+), 20 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5b9ed89bf7..52862e3483 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), @@ -273,6 +264,15 @@ fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -1160,7 +1160,6 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), app_server_client: sample_app_server_client_metadata(), runtime: sample_runtime_metadata(), submission_type: None, @@ -1211,7 +1210,6 @@ fn turn_event_serializes_expected_shape() { "event_params": { "thread_id": "thread-2", "turn_id": "turn-2", - "product_client_id": "codex-tui", "submission_type": null, "app_server_client": { "product_client_id": "codex-tui", @@ -1648,10 +1646,7 @@ async fn turn_lifecycle_emits_turn_event() { "runtime_arch": "aarch64", }) ); - assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") - ); + assert!(payload["event_params"].get("product_client_id").is_none()); assert_eq!(payload["event_params"]["ephemeral"], json!(false)); assert_eq!(payload["event_params"]["thread_source"], json!("user")); assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 79e4431648..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -127,7 +127,6 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, pub(crate) submission_type: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 4fdec9f7bf..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -12,6 +12,7 @@ pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7065d0ad54..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -740,15 +742,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id: app_server_client.product_client_id.clone(), app_server_client, runtime, submission_type, ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 3c7664656d..cb1fcd14fc 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -299,12 +299,23 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1f1432cf0c..11480fcb43 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -651,6 +651,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1076,6 +1077,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1137,6 +1146,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1161,6 +1171,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1534,6 +1545,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f0bded7187..615ca14e62 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1872,6 +1873,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1974,6 +1976,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2323,6 +2326,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2586,6 +2590,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3531,6 +3537,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From cb42dd28f82691df6128f4040d5ea9e8f50b7482 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 14:23:17 -0700 Subject: [PATCH 246/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 530 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 61 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 398 ++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++ codex-rs/core/src/codex.rs | 76 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 29 files changed, 1458 insertions(+), 55 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..6ee71e1e3e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,7 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,29 +26,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +131,201 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1035,322 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + product_client_id: "codex-tui".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "product_client_id": "codex-tui", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert_eq!( + payload["event_params"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..df5accc188 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,7 +2,11 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -17,14 +21,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -37,6 +33,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +119,54 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) product_client_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..d36f8ee994 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +144,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..1d9e02b4b6 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,7 +9,10 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..ffca9605fb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,9 +5,10 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,11 +27,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,162 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + product_client_id: app_server_client.product_client_id.clone(), + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 976518465b..b21a0f0ee0 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1731,6 +1738,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1908,6 +1916,7 @@ 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 { @@ -1922,6 +1931,10 @@ 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; @@ -2114,6 +2127,7 @@ 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>, ) { @@ -2134,6 +2148,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2143,6 +2158,7 @@ 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>, ) { @@ -2158,6 +2174,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2897,6 +2914,7 @@ 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; @@ -3018,6 +3036,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3032,6 +3051,7 @@ 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(), @@ -3340,6 +3360,13 @@ 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(), }; @@ -3748,6 +3775,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3796,6 +3824,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3843,6 +3872,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4109,6 +4139,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4130,6 +4161,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4141,6 +4173,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1e10a5696d..7f1b535353 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6665,6 +6665,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7449,6 +7458,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 59591dd0d1..4c6fc90bc0 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +244,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 66f7c020a5..7da6d3fe32 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,8 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +188,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -428,6 +431,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -482,6 +486,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -646,6 +651,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -672,6 +678,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1131,6 +1138,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1155,6 +1163,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1521,6 +1530,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1925,11 +1935,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2244,6 +2256,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6012,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6298,6 +6317,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index aef0a92d76..9aea3e5594 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index d57d92c1ec..f0bded7187 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index ad62db4d79..ba75498f75 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -519,6 +519,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 5200d252c4..3add0f3ea7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1099,6 +1099,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6912243ab2..6bad6a3511 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2279,6 +2279,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From d7940aa8fd9e0734e94b41b9fe6bde7b32152054 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 14:38:27 -0700 Subject: [PATCH 247/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 6ee71e1e3e..f03f5088a1 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -67,6 +68,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -182,6 +184,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -236,6 +252,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -305,6 +322,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1075,6 +1103,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1132,6 +1165,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1154,6 +1192,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1206,6 +1245,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1219,6 +1266,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1243,6 +1291,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1270,6 +1319,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1301,6 +1351,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1332,6 +1383,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1349,6 +1401,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index df5accc188..96a636d8be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -156,6 +156,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d36f8ee994..b772a5d12b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -145,6 +153,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1d9e02b4b6..19f320eaa9 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index ffca9605fb..46f534b580 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -544,6 +574,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -582,6 +613,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 4c6fc90bc0..3c7664656d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -310,6 +310,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From d86cec92be025a684d1063bc0667faa6fcefb5a5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 14:39:27 -0700 Subject: [PATCH 248/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 528 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 63 +++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 973 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f03f5088a1..802da538dd 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,173 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, + out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +430,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1094,7 +1246,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1156,7 +1308,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1181,6 +1333,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1242,6 +1614,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1255,6 +1628,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 96a636d8be..79e4431648 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,10 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -34,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -172,6 +176,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -263,6 +286,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b772a5d12b..7c93bc2b91 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -85,6 +87,14 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -93,6 +103,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -144,6 +201,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 19f320eaa9..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,7 +5,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -13,6 +16,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 46f534b580..7065d0ad54 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -15,28 +16,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -604,7 +766,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -673,6 +835,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7f1b535353..999912b1cf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -497,6 +500,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6566,12 +6585,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6583,6 +6608,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6682,6 +6708,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6706,23 +6733,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6739,19 +6774,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6764,11 +6814,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6781,12 +6829,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6794,6 +6852,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f8ae10764..2728ca1de0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From f81b862ef33829b0d7246f40bcb6d91d5ac73a86 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 14:39:27 -0700 Subject: [PATCH 249/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 25 ++++++++----------- codex-rs/analytics/src/events.rs | 25 ++++++++++++++++++- codex-rs/analytics/src/reducer.rs | 7 +++--- .../app-server/tests/suite/v2/turn_start.rs | 13 +++++++++- codex-rs/core/src/codex.rs | 10 ++++++++ codex-rs/core/src/codex_tests.rs | 7 ++++++ 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 802da538dd..becb0009bc 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), @@ -273,6 +264,15 @@ fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -1222,7 +1222,6 @@ fn turn_event_serializes_expected_shape() { event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), - product_client_id: "codex-tui".to_string(), app_server_client: sample_app_server_client_metadata(), runtime: sample_runtime_metadata(), submission_type: None, @@ -1273,7 +1272,6 @@ fn turn_event_serializes_expected_shape() { "event_params": { "thread_id": "thread-2", "turn_id": "turn-2", - "product_client_id": "codex-tui", "submission_type": null, "app_server_client": { "product_client_id": "codex-tui", @@ -1603,10 +1601,7 @@ async fn turn_lifecycle_emits_turn_event() { "runtime_arch": "aarch64", }) ); - assert_eq!( - payload["event_params"]["product_client_id"], - json!("codex-tui") - ); + assert!(payload["event_params"].get("product_client_id").is_none()); assert_eq!(payload["event_params"]["ephemeral"], json!(false)); assert_eq!(payload["event_params"]["thread_source"], json!("user")); assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 79e4431648..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -127,7 +127,6 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, - pub(crate) product_client_id: String, pub(crate) submission_type: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -368,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7065d0ad54..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -740,15 +742,14 @@ fn codex_turn_event_params( CodexTurnEventParams { thread_id, turn_id, - product_client_id: app_server_client.product_client_id.clone(), app_server_client, runtime, submission_type, ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 3c7664656d..cb1fcd14fc 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -299,12 +299,23 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["thread_id"], thread.id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( - event["event_params"]["product_client_id"], + event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME ); assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7da6d3fe32..11480fcb43 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1077,6 +1077,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1537,6 +1545,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f0bded7187..615ca14e62 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1872,6 +1873,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1974,6 +1976,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2323,6 +2326,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2586,6 +2590,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3531,6 +3537,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, From f58ed8990c639bcb75eb20482a34f37e97edd531 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 15:26:42 -0700 Subject: [PATCH 250/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 525 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 ++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 142 +++++ codex-rs/core/src/codex.rs | 76 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 29 files changed, 1451 insertions(+), 55 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..467569d7a5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,7 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,29 +26,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +131,201 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1035,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..19b1bc049e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,7 +2,11 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -17,14 +21,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -37,6 +33,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +119,53 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..d36f8ee994 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +144,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..1d9e02b4b6 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,7 +9,10 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..934a014b4f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,9 +5,10 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,11 +27,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,161 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 976518465b..b21a0f0ee0 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1731,6 +1738,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1908,6 +1916,7 @@ 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 { @@ -1922,6 +1931,10 @@ 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; @@ -2114,6 +2127,7 @@ 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>, ) { @@ -2134,6 +2148,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2143,6 +2158,7 @@ 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>, ) { @@ -2158,6 +2174,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2897,6 +2914,7 @@ 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; @@ -3018,6 +3036,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3032,6 +3051,7 @@ 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(), @@ -3340,6 +3360,13 @@ 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(), }; @@ -3748,6 +3775,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3796,6 +3824,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3843,6 +3872,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4109,6 +4139,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4130,6 +4161,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4141,6 +4173,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1e10a5696d..7f1b535353 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6665,6 +6665,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7449,6 +7458,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index fbc1bd6d10..0f8ae10764 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..2c92a47628 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,7 +39,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -173,7 +173,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -206,7 +206,7 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 59591dd0d1..e8442a9b5a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; +use super::analytics::wait_for_analytics_payload; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -238,6 +244,142 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace("general_analytics = true", "general_analytics = false"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let payload = wait_for_analytics_payload(&server, std::time::Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 66f7c020a5..7da6d3fe32 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,8 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; @@ -186,6 +188,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -428,6 +431,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -482,6 +486,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -646,6 +651,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -672,6 +678,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1131,6 +1138,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1155,6 +1163,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1521,6 +1530,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1925,11 +1935,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2244,6 +2256,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6012,6 +6029,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6298,6 +6317,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index aef0a92d76..9aea3e5594 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index d57d92c1ec..f0bded7187 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2620,6 +2620,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 4f60c2f28e..7738cbbe91 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -457,6 +457,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9727cc208a..4a2f5dae24 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -42,6 +43,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index ad62db4d79..ba75498f75 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -519,6 +519,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 5200d252c4..3add0f3ea7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1099,6 +1099,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6912243ab2..6bad6a3511 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2279,6 +2279,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 0681ff66224d454b634b197314efbdf634a5f117 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 15:26:54 -0700 Subject: [PATCH 251/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 467569d7a5..2c751bb1b8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -67,6 +68,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -182,6 +184,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -236,6 +252,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -305,6 +322,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1074,6 +1102,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1130,6 +1163,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1152,6 +1190,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1201,6 +1240,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1214,6 +1261,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1238,6 +1286,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1265,6 +1314,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1296,6 +1346,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1327,6 +1378,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1344,6 +1396,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 19b1bc049e..feef46a386 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -155,6 +155,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d36f8ee994..b772a5d12b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -145,6 +153,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1d9e02b4b6..19f320eaa9 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 934a014b4f..76955a3117 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -544,6 +574,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -581,6 +612,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 e8442a9b5a..e33d3faa1e 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -310,6 +310,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 05cd2af4d50a488b5ff525659a7805cc312fddf4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 15:31:42 -0700 Subject: [PATCH 252/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 518 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 955 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2c751bb1b8..f1a0191316 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,163 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, + out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +420,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1093,7 +1235,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1154,7 +1296,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1179,6 +1321,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1237,6 +1599,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1250,6 +1613,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index feef46a386..562eecff55 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,10 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -34,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b772a5d12b..f06bf3e8d3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -144,6 +193,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 19f320eaa9..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,7 +5,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -13,6 +16,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 76955a3117..9a0d2ca2de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -15,28 +16,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +765,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +834,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7f1b535353..999912b1cf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -497,6 +500,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6566,12 +6585,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6583,6 +6608,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6682,6 +6708,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6706,23 +6733,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6739,19 +6774,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6764,11 +6814,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6781,12 +6829,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6794,6 +6852,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f8ae10764..2728ca1de0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 5d1b3cc22e..76da7efb78 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -67,6 +76,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -95,7 +119,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -198,7 +227,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -258,31 +292,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 92f515bdf8e642700e9089a2477badfa1a873934 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 15:31:42 -0700 Subject: [PATCH 253/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 9 ------- codex-rs/analytics/src/events.rs | 24 +++++++++++++++++++ codex-rs/analytics/src/reducer.rs | 6 +++-- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++++++ codex-rs/core/src/codex.rs | 10 ++++++++ codex-rs/core/src/codex_tests.rs | 7 ++++++ 6 files changed, 56 insertions(+), 11 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f1a0191316..f4073c729a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 562eecff55..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -367,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 9a0d2ca2de..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -746,8 +748,8 @@ fn codex_turn_event_params( ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 e33d3faa1e..cb1fcd14fc 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -305,6 +305,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7da6d3fe32..11480fcb43 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1077,6 +1077,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1537,6 +1545,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f0bded7187..615ca14e62 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::ToolInfo; @@ -1872,6 +1873,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -1974,6 +1976,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2323,6 +2326,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2586,6 +2590,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2690,6 +2695,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3531,6 +3537,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, From b7e0069d45e77176b7aec5070f51def0dcdc215c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:38:10 -0700 Subject: [PATCH 254/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 525 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 ++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 9 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 76 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 34 files changed, 1501 insertions(+), 70 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..467569d7a5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,7 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,29 +26,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +131,201 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1035,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..19b1bc049e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,7 +2,11 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -17,14 +21,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -37,6 +33,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +119,53 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..d36f8ee994 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +144,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..1d9e02b4b6 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,7 +9,10 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..934a014b4f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,9 +5,10 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,11 +27,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,161 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a6a5951be8..d232c44089 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1731,6 +1738,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1908,6 +1916,7 @@ 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 { @@ -1922,6 +1931,10 @@ 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; @@ -2114,6 +2127,7 @@ 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>, ) { @@ -2134,6 +2148,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2143,6 +2158,7 @@ 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>, ) { @@ -2158,6 +2174,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2897,6 +2914,7 @@ 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; @@ -3018,6 +3036,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3032,6 +3051,7 @@ 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(), @@ -3343,6 +3363,13 @@ 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(), }; @@ -3751,6 +3778,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3799,6 +3827,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3846,6 +3875,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4112,6 +4142,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4133,6 +4164,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4144,6 +4176,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b060112fab..ce8270c7bc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6636,6 +6636,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7436,6 +7445,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 83773c34d0..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..3a29b5a6ac 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,21 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env( + codex_home, + &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))], + ) + .await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9c71df3afc..a5cc1e2ec5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,8 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -186,6 +188,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -434,6 +437,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -486,6 +490,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -650,6 +655,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -676,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1166,6 +1173,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1190,6 +1198,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1604,6 +1613,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -2002,11 +2012,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2329,6 +2341,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6164,6 +6181,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6465,6 +6484,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3d74773ba7..a19c44a6eb 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 44f5a2b7ef..10a5187bf1 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2735,6 +2735,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 30879fc82a..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -454,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f95c615a67..60ff23a6b8 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index eaa9f8e2b4..a3b11d71b8 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -521,6 +521,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a2a110b2df..7811654a2e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1103,6 +1103,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f1bbc0a7ff..80d1535fc2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2416,6 +2416,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From e8af2e57f1e3b72ef21bb3c5b9828bda7c6e5ff0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:38:10 -0700 Subject: [PATCH 255/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 467569d7a5..2c751bb1b8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -67,6 +68,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -182,6 +184,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -236,6 +252,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -305,6 +322,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1074,6 +1102,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1130,6 +1163,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1152,6 +1190,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1201,6 +1240,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1214,6 +1261,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1238,6 +1286,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1265,6 +1314,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1296,6 +1346,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1327,6 +1378,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1344,6 +1396,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 19b1bc049e..feef46a386 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -155,6 +155,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d36f8ee994..b772a5d12b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -145,6 +153,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1d9e02b4b6..19f320eaa9 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 934a014b4f..76955a3117 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -544,6 +574,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -581,6 +612,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From ba4de0d6d6bb3b6eecfcaf3cc522aa7a1cba46a5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:38:10 -0700 Subject: [PATCH 256/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 519 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 956 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2c751bb1b8..90b93052f2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,164 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, + out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +421,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1093,7 +1236,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1154,7 +1297,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1179,6 +1322,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1237,6 +1600,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1250,6 +1614,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index feef46a386..562eecff55 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,10 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -34,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b772a5d12b..f06bf3e8d3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -144,6 +193,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 19f320eaa9..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,7 +5,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -13,6 +16,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 76955a3117..9a0d2ca2de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -15,28 +16,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +765,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +834,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ce8270c7bc..f368aefab4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -503,6 +506,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6536,12 +6555,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6553,6 +6578,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6653,6 +6679,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6677,23 +6704,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6714,19 +6749,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6739,11 +6789,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6756,12 +6804,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6769,6 +6827,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 69dd52ae19313c640ea79f5cb4747d431d715bb2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:38:10 -0700 Subject: [PATCH 257/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 9 ------- codex-rs/analytics/src/events.rs | 24 +++++++++++++++++++ codex-rs/analytics/src/reducer.rs | 6 +++-- .../app-server/src/codex_message_processor.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 11 +++++++++ codex-rs/core/src/codex.rs | 10 ++++++++ codex-rs/core/src/codex_tests.rs | 7 ++++++ 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 90b93052f2..5daa3c5971 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 562eecff55..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -367,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 9a0d2ca2de..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -746,8 +748,8 @@ fn codex_turn_event_params( ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f368aefab4..503d17a050 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9374,6 +9374,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a5cc1e2ec5..6c4d1e536b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1092,6 +1092,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1620,6 +1628,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 10a5187bf1..88b40c6d57 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1987,6 +1988,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2089,6 +2091,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2438,6 +2441,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2701,6 +2705,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2805,6 +2810,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3650,6 +3656,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, From 9098f5f1af6cd834a71bb8db802f5fb54db3eec0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:53:25 -0700 Subject: [PATCH 258/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 525 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 ++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 9 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 76 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 34 files changed, 1508 insertions(+), 70 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..467569d7a5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,7 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,29 +26,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +131,201 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1035,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..19b1bc049e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,7 +2,11 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -17,14 +21,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -37,6 +33,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +119,53 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..d36f8ee994 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +144,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..1d9e02b4b6 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,7 +9,10 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..934a014b4f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,9 +5,10 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,11 +27,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,161 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a6a5951be8..d232c44089 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1731,6 +1738,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1908,6 +1916,7 @@ 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 { @@ -1922,6 +1931,10 @@ 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; @@ -2114,6 +2127,7 @@ 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>, ) { @@ -2134,6 +2148,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2143,6 +2158,7 @@ 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>, ) { @@ -2158,6 +2174,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2897,6 +2914,7 @@ 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; @@ -3018,6 +3036,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3032,6 +3051,7 @@ 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(), @@ -3343,6 +3363,13 @@ 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(), }; @@ -3751,6 +3778,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3799,6 +3827,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3846,6 +3875,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4112,6 +4142,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4133,6 +4164,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4144,6 +4176,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b060112fab..ce485cae65 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6636,6 +6636,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7436,6 +7445,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9303,6 +9315,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 83773c34d0..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..3a29b5a6ac 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,21 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env( + codex_home, + &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))], + ) + .await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9c71df3afc..a5cc1e2ec5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,8 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -186,6 +188,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -434,6 +437,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -486,6 +490,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -650,6 +655,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -676,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1166,6 +1173,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1190,6 +1198,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1604,6 +1613,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -2002,11 +2012,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2329,6 +2341,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6164,6 +6181,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6465,6 +6484,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3d74773ba7..a19c44a6eb 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 44f5a2b7ef..7497cf5822 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1987,6 +1987,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2089,6 +2090,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2438,6 +2440,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2701,6 +2704,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2735,6 +2739,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; @@ -2804,6 +2809,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3649,6 +3655,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 30879fc82a..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -454,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f95c615a67..60ff23a6b8 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index eaa9f8e2b4..a3b11d71b8 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -521,6 +521,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a2a110b2df..7811654a2e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1103,6 +1103,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f1bbc0a7ff..80d1535fc2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2416,6 +2416,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 3f7d0bfb0f4711d32cd9368bb3a75e81de26e7c1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:53:36 -0700 Subject: [PATCH 259/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 467569d7a5..2c751bb1b8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -67,6 +68,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -182,6 +184,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -236,6 +252,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -305,6 +322,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1074,6 +1102,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1130,6 +1163,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1152,6 +1190,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1201,6 +1240,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1214,6 +1261,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1238,6 +1286,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1265,6 +1314,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1296,6 +1346,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1327,6 +1378,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1344,6 +1396,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 19b1bc049e..feef46a386 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -155,6 +155,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d36f8ee994..b772a5d12b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -145,6 +153,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1d9e02b4b6..19f320eaa9 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 934a014b4f..76955a3117 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -544,6 +574,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -581,6 +612,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 459295c066da4cc3f4da93b00cdd8c0365037312 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:53:36 -0700 Subject: [PATCH 260/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 519 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 956 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2c751bb1b8..90b93052f2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,164 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, + out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +421,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1093,7 +1236,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1154,7 +1297,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1179,6 +1322,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1237,6 +1600,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1250,6 +1614,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index feef46a386..562eecff55 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,10 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -34,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b772a5d12b..f06bf3e8d3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -144,6 +193,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 19f320eaa9..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,7 +5,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -13,6 +16,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 76955a3117..9a0d2ca2de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -15,28 +16,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +765,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +834,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ce485cae65..503d17a050 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -503,6 +506,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6536,12 +6555,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6553,6 +6578,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6653,6 +6679,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6677,23 +6704,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6714,19 +6749,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6739,11 +6789,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6756,12 +6804,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6769,6 +6827,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 49a1322ec34195d606008495be7fc94f06477303 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 16:53:36 -0700 Subject: [PATCH 261/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 9 ------- codex-rs/analytics/src/events.rs | 24 +++++++++++++++++++ codex-rs/analytics/src/reducer.rs | 6 +++-- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++++++ codex-rs/core/src/codex.rs | 10 ++++++++ codex-rs/core/src/codex_tests.rs | 1 + 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 90b93052f2..5daa3c5971 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 562eecff55..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -367,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 9a0d2ca2de..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -746,8 +748,8 @@ fn codex_turn_event_params( ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a5cc1e2ec5..6c4d1e536b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1092,6 +1092,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1620,6 +1628,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 7497cf5822..88b40c6d57 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From 135cd488f7418af78fe6e53f6a55ee3d1230dc01 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 17:23:48 -0700 Subject: [PATCH 262/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 515 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 952 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2c751bb1b8..c8b688d469 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,160 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +417,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1093,7 +1232,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1154,7 +1293,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1179,6 +1318,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1237,6 +1596,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1250,6 +1610,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index feef46a386..562eecff55 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,10 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -34,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b772a5d12b..f06bf3e8d3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -144,6 +193,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 19f320eaa9..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,7 +5,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -13,6 +16,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 76955a3117..9a0d2ca2de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -15,28 +16,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +765,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +834,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ce485cae65..503d17a050 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -503,6 +506,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6536,12 +6555,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6553,6 +6578,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } @@ -6653,6 +6679,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; } } @@ -6677,23 +6704,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6714,19 +6749,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6739,11 +6789,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6756,12 +6804,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6769,6 +6827,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From dd2f5634185e27f76af2bb64daadb84ff7355e44 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 17:23:48 -0700 Subject: [PATCH 263/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 525 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 ++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 20 +- codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 76 ++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 7 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/state/session.rs | 12 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 4 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 + 34 files changed, 1504 insertions(+), 70 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 73ea42d760..467569d7a5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,7 +6,7 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,29 +26,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -114,6 +131,201 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { + CodexAppServerClientMetadata { + product_client_id: "codex-tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: None, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -823,6 +1035,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index dd300a7bd6..e61affe76c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -178,6 +190,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -227,6 +245,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 885e93bbb9..19b1bc049e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,7 +2,11 @@ use crate::facts::AppInvocation; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -17,14 +21,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -37,6 +33,7 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -122,6 +119,53 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e19d15d847..d36f8ee994 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -89,6 +144,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index f2f76ca8cf..1d9e02b4b6 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,7 +9,10 @@ pub use facts::AppInvocation; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 63b9c3d5be..934a014b4f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,9 +5,10 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -26,11 +27,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -39,6 +52,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, } @@ -47,6 +62,32 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -66,21 +107,28 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -135,6 +183,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -235,24 +329,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let Some(connection_state) = self.connections.get(&connection_id) else { return; @@ -275,6 +469,161 @@ impl AnalyticsReducer { }, )); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a6a5951be8..d232c44089 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1731,6 +1738,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1908,6 +1916,7 @@ 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 { @@ -1922,6 +1931,10 @@ 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; @@ -2114,6 +2127,7 @@ 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>, ) { @@ -2134,6 +2148,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2143,6 +2158,7 @@ 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>, ) { @@ -2158,6 +2174,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2897,6 +2914,7 @@ 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; @@ -3018,6 +3036,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3032,6 +3051,7 @@ 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(), @@ -3343,6 +3363,13 @@ 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(), }; @@ -3751,6 +3778,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3799,6 +3827,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3846,6 +3875,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4112,6 +4142,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4133,6 +4164,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4144,6 +4176,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b060112fab..ce485cae65 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6636,6 +6636,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7436,6 +7445,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9303,6 +9315,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 83773c34d0..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -223,6 +223,11 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -233,12 +238,8 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + Some(analytics_events_client.clone()), )); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); @@ -676,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..69d209c020 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9c71df3afc..a5cc1e2ec5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,8 @@ use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -186,6 +188,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -434,6 +437,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) analytics_events_client: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -486,6 +490,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -650,6 +655,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -676,6 +682,7 @@ impl Codex { skills_watcher, agent_control, environment, + analytics_events_client, ) .await .map_err(|e| { @@ -1166,6 +1173,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1190,6 +1198,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -1604,6 +1613,7 @@ impl Session { skills_watcher: Arc, agent_control: AgentControl, environment: Option>, + analytics_events_client: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -2002,11 +2012,13 @@ impl Session { ), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ), + analytics_events_client: analytics_events_client.unwrap_or_else(|| { + AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) + }), hooks, rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), @@ -2329,6 +2341,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6164,6 +6181,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6465,6 +6484,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3d74773ba7..a19c44a6eb 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 44f5a2b7ef..7497cf5822 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1987,6 +1987,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2089,6 +2090,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2438,6 +2440,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2701,6 +2704,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2735,6 +2739,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { .await .expect("create environment"), )), + /*analytics_events_client*/ None, ) .await; @@ -2804,6 +2809,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3649,6 +3655,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 30879fc82a..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -454,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f95c615a67..60ff23a6b8 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 5fe39ee88f..789aac5806 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -39,6 +39,7 @@ pub async fn build_prompt_input( .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(EnvironmentManager::from_env()), + /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2dd4ad5532..af83785c55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -15,6 +15,7 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; use codex_exec_server::EnvironmentManager; @@ -210,6 +211,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, session_source: SessionSource, + analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -221,6 +223,7 @@ impl ThreadManager { session_source: SessionSource, collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, + analytics_events_client: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -259,6 +262,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source, + analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -328,6 +332,7 @@ impl ThreadManager { skills_watcher, auth_manager, session_source: SessionSource::Exec, + analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -916,6 +921,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + analytics_events_client: self.analytics_events_client.clone(), }) .await?; self.finalize_thread_spawn(codex, thread_id, watch_registration) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3db1735822..d3a56ce5ea 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -299,6 +299,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -435,6 +436,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -537,6 +539,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager @@ -629,6 +632,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let source = manager diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index eaa9f8e2b4..a3b11d71b8 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -521,6 +521,7 @@ impl TestCodexBuilder { SessionSource::Exec, CollaborationModesConfig::default(), Arc::clone(&environment_manager), + /*analytics_events_client*/ None, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a2a110b2df..7811654a2e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1103,6 +1103,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Arc::new(codex_exec_server::EnvironmentManager::new( /*exec_server_url*/ None, )), + /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config) diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 46470f994a..e0a1e77f68 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -70,6 +70,7 @@ impl MessageProcessor { .enabled(Feature::DefaultModeRequestUserInput), }, environment_manager, + /*analytics_events_client*/ None, )); Self { outgoing, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f1bbc0a7ff..80d1535fc2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2416,6 +2416,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 3cbd03b1af05c8119874b4f1b3cfa455c56edac5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 17:23:48 -0700 Subject: [PATCH 264/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 9 ------- codex-rs/analytics/src/events.rs | 24 +++++++++++++++++++ codex-rs/analytics/src/reducer.rs | 6 +++-- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++++++ codex-rs/core/src/codex.rs | 10 ++++++++ codex-rs/core/src/codex_tests.rs | 1 + 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c8b688d469..6d7ce36ef5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 562eecff55..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -367,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 9a0d2ca2de..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -746,8 +748,8 @@ fn codex_turn_event_params( ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a5cc1e2ec5..6c4d1e536b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1092,6 +1092,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1620,6 +1628,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 7497cf5822..88b40c6d57 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From af2db0f369717bf932f79f50a33c21cce8ca32b1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 17:23:48 -0700 Subject: [PATCH 265/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 467569d7a5..2c751bb1b8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -30,6 +30,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -67,6 +68,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -182,6 +184,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -236,6 +252,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -305,6 +322,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1074,6 +1102,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1130,6 +1163,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1152,6 +1190,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1201,6 +1240,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1214,6 +1261,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1238,6 +1286,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1265,6 +1314,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1296,6 +1346,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1327,6 +1378,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1344,6 +1396,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e61affe76c..41802bdb0e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -196,6 +197,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 19b1bc049e..feef46a386 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -155,6 +155,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d36f8ee994..b772a5d12b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -145,6 +153,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1d9e02b4b6..19f320eaa9 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -13,6 +13,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 934a014b4f..76955a3117 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -30,6 +30,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -46,6 +47,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -85,6 +87,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -129,6 +132,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -221,6 +227,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -229,6 +236,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -373,6 +400,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -397,6 +425,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -414,6 +443,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -544,6 +574,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -581,6 +612,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5da3650bf8..d246e69cac 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -485,6 +486,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 2b2db7bb97ea1291bcad122287d93ab19024c5a0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 17:23:48 -0700 Subject: [PATCH 266/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 515 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 952 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2c751bb1b8..c8b688d469 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,160 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +417,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1093,7 +1232,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1154,7 +1293,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1179,6 +1318,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1237,6 +1596,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1250,6 +1610,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index feef46a386..562eecff55 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,10 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -34,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b772a5d12b..f06bf3e8d3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -144,6 +193,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 19f320eaa9..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,7 +5,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -13,6 +16,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 76955a3117..9a0d2ca2de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -15,28 +16,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +765,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +834,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ce485cae65..8fa6614033 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -503,6 +506,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6536,12 +6555,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6553,6 +6578,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6653,6 +6679,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6677,23 +6704,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, /*error_type*/ None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6714,19 +6749,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6739,11 +6789,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6756,12 +6804,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6769,6 +6827,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 0294922b786c2be3e85a50843e7920be0e993255 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 9 Apr 2026 17:23:48 -0700 Subject: [PATCH 267/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 9 ------- codex-rs/analytics/src/events.rs | 24 +++++++++++++++++++ codex-rs/analytics/src/reducer.rs | 6 +++-- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++++++ codex-rs/core/src/codex.rs | 10 ++++++++ codex-rs/core/src/codex_tests.rs | 1 + 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c8b688d469..6d7ce36ef5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 562eecff55..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -367,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 9a0d2ca2de..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -746,8 +748,8 @@ fn codex_turn_event_params( ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a5cc1e2ec5..6c4d1e536b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1092,6 +1092,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1620,6 +1628,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 7497cf5822..88b40c6d57 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From b940eee9abc10e790528d744d8ee1f873f56f6d5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 11:36:34 -0700 Subject: [PATCH 268/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 524 ++++++++++++++++-- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 952 insertions(+), 93 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 2c751bb1b8..ff4610206c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,10 +15,12 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -30,6 +32,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -39,8 +42,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -55,6 +61,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -246,6 +254,160 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -255,30 +417,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -335,15 +474,6 @@ async fn ingest_turn_prerequisites( } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), @@ -1093,7 +1223,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1154,7 +1284,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1179,6 +1309,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1237,6 +1587,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1250,6 +1601,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 41802bdb0e..c375908263 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -253,6 +255,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index feef46a386..562eecff55 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,10 +1,13 @@ use crate::facts::AppInvocation; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -34,6 +37,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -171,6 +175,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -262,6 +285,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b772a5d12b..f06bf3e8d3 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -144,6 +193,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 19f320eaa9..41b5c64813 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,7 +5,10 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::CodexTurnSteerEvent; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -13,6 +16,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 76955a3117..9a0d2ca2de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -7,6 +7,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -15,28 +16,38 @@ use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -51,6 +62,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -66,6 +79,7 @@ struct ConnectionState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -73,6 +87,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -89,6 +110,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -122,6 +144,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -195,22 +225,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -229,6 +266,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -250,6 +288,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -402,16 +441,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -427,6 +523,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -445,6 +542,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -500,6 +598,70 @@ impl AnalyticsReducer { )); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -603,7 +765,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -672,6 +834,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ce485cae65..8fa6614033 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -35,7 +38,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -503,6 +506,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6536,12 +6555,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6553,6 +6578,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6653,6 +6679,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6677,23 +6704,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, /*error_type*/ None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6714,19 +6749,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6739,11 +6789,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6756,12 +6804,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6769,6 +6827,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From c815dc687058e8bbc7058a7e2dd05bffded3b243 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 11:42:46 -0700 Subject: [PATCH 269/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 18 +++++++------- codex-rs/analytics/src/events.rs | 24 +++++++++++++++++++ codex-rs/analytics/src/reducer.rs | 6 +++-- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++++++ codex-rs/core/src/codex.rs | 10 ++++++++ codex-rs/core/src/codex_tests.rs | 1 + 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ff4610206c..6d7ce36ef5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -474,6 +465,15 @@ async fn ingest_turn_prerequisites( } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 562eecff55..647ef055be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -367,3 +367,27 @@ fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option _ => None, } } + +pub(crate) fn turn_subagent_source_name(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} + +pub(crate) fn turn_parent_thread_id(thread_source: &SessionSource) -> Option { + match thread_source { + SessionSource::SubAgent(subagent_source) => subagent_parent_thread_id(subagent_source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => None, + } +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 9a0d2ca2de..86e9c8dd07 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -20,6 +20,8 @@ use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; +use crate::events::turn_parent_thread_id; +use crate::events::turn_subagent_source_name; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; @@ -746,8 +748,8 @@ fn codex_turn_event_params( ephemeral, thread_source: thread_source_name(&session_source).map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: turn_subagent_source_name(&session_source), + parent_thread_id: turn_parent_thread_id(&session_source), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a5cc1e2ec5..6c4d1e536b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1092,6 +1092,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1620,6 +1628,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 7497cf5822..88b40c6d57 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From c36ffd65554c2a0a4c6a2708dd149eccb3c233f0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 12:26:22 -0700 Subject: [PATCH 270/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 30 ++++++--- codex-rs/analytics/src/events.rs | 4 +- codex-rs/analytics/src/reducer.rs | 63 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++ codex-rs/core/src/codex.rs | 10 +++ codex-rs/core/src/codex_tests.rs | 1 + 6 files changed, 100 insertions(+), 19 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ff4610206c..636b3647da 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -254,15 +254,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -418,6 +409,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -474,6 +477,15 @@ async fn ingest_turn_prerequisites( } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: "codex-tui".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 562eecff55..eebe74d8bf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -349,7 +349,7 @@ pub(crate) fn subagent_thread_started_event_request( } } -fn subagent_source_name(subagent_source: &SubAgentSource) -> String { +pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String { match subagent_source { SubAgentSource::Review => "review".to_string(), SubAgentSource::Compact => "compact".to_string(), @@ -359,7 +359,7 @@ fn subagent_source_name(subagent_source: &SubAgentSource) -> String { } } -fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option { +pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option { match subagent_source { SubAgentSource::ThreadSpawn { parent_thread_id, .. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 9a0d2ca2de..6567eacc8a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -18,6 +18,8 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; +use crate::events::subagent_parent_thread_id; +use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; @@ -70,6 +72,8 @@ pub(crate) struct AnalyticsReducer { requests: HashMap<(u64, RequestId), RequestState>, turns: HashMap, connections: HashMap, + thread_connections: HashMap, + thread_metadata: HashMap, } struct ConnectionState { @@ -77,6 +81,35 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +#[derive(Clone)] +struct ThreadMetadataState { + thread_source: Option<&'static str>, + subagent_source: Option, + parent_thread_id: Option, +} + +impl ThreadMetadataState { + fn from_session_source(session_source: &SessionSource) -> Self { + let (subagent_source, parent_thread_id) = match session_source { + SessionSource::SubAgent(subagent_source) => ( + Some(subagent_source_name(subagent_source)), + subagent_parent_thread_id(subagent_source), + ), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => (None, None), + }; + Self { + thread_source: thread_source_name(session_source), + subagent_source, + parent_thread_id, + } + } +} + enum RequestState { TurnStart(PendingTurnStartState), TurnSteer(PendingTurnSteerState), @@ -576,22 +609,28 @@ impl AnalyticsReducer { out: &mut Vec, ) { let thread_source: SessionSource = thread.source.into(); + let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; + let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + self.thread_connections + .insert(thread_id.clone(), connection_id); + self.thread_metadata + .insert(thread_id.clone(), thread_metadata.clone()); out.push(TrackEventRequest::ThreadInitialized( ThreadInitializedEvent { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { - thread_id: thread.id, + thread_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), model, ephemeral: thread.ephemeral, - thread_source: thread_source_name(&thread_source), + thread_source: thread_metadata.thread_source, initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source, + parent_thread_id: thread_metadata.parent_thread_id, created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, }, @@ -685,6 +724,12 @@ impl AnalyticsReducer { let Some((app_server_client, runtime)) = connection_metadata else { return; }; + let Some(thread_id) = turn_state.thread_id.as_ref() else { + return; + }; + let Some(thread_metadata) = self.thread_metadata.get(thread_id) else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", @@ -693,6 +738,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -705,6 +751,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -721,7 +768,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -744,10 +791,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a5cc1e2ec5..6c4d1e536b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1092,6 +1092,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1620,6 +1628,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 7497cf5822..88b40c6d57 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From bc876c3c101043f0daa0a0026eb84f48cdcb6dfe Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 271/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 515 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 60 ++ codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1462 insertions(+), 60 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..326141e10b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,191 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1289,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..30187cdcf2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -184,6 +196,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -233,6 +251,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 97ebe1f046..cfad296a9f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -18,14 +22,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -39,6 +35,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -153,6 +150,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..1096da3502 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -154,6 +209,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..ade7c21f32 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,7 +16,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..5e09ce78a5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,9 +6,10 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -31,11 +32,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -44,6 +57,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -83,6 +98,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -102,17 +143,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -120,6 +165,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +222,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -274,24 +368,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -361,6 +555,161 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ce71e8974d..0b703542e5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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::AddCreditsNudgeEmailNotification; use codex_app_server_protocol::AddCreditsNudgeEmailResult; @@ -170,6 +171,7 @@ 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, @@ -205,6 +207,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -221,6 +227,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1756,6 +1763,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1933,6 +1941,7 @@ 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 { @@ -1947,6 +1956,10 @@ 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; @@ -2139,6 +2152,7 @@ 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>, ) { @@ -2159,6 +2173,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2168,6 +2183,7 @@ 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>, ) { @@ -2183,6 +2199,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2922,6 +2939,7 @@ 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; @@ -3043,6 +3061,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3057,6 +3076,7 @@ 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(), @@ -3368,6 +3388,13 @@ 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(), }; @@ -3776,6 +3803,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3824,6 +3852,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3871,6 +3900,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4138,6 +4168,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4159,6 +4190,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4170,6 +4202,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 795723f9d3..0e80289673 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6836,6 +6836,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7647,6 +7656,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9514,6 +9526,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0130966213..729c4a2db7 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -99,12 +99,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea0375b144..866c11c429 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +190,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -654,6 +657,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1171,6 +1175,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1195,6 +1200,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -2338,6 +2344,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6234,6 +6245,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6537,6 +6550,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 144d9a971b..41d7cd4d03 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -96,6 +96,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 66c116ee58..a9c29c56d8 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1991,6 +1991,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2096,6 +2097,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2451,6 +2453,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2714,6 +2717,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2818,6 +2822,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3663,6 +3668,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..55fe4ab040 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -455,6 +455,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e90dd0f80a..f7b2904659 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ce01994e4a..93c17bc03e 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 44693033d8..6fc203c8ab 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2453,6 +2453,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 5076c1158b83b3089580df3d89eb951c5aa52d06 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 272/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 326141e10b..8be1a7bc56 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -285,6 +301,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -354,6 +371,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1328,6 +1356,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1384,6 +1417,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1406,6 +1444,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1455,6 +1494,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1468,6 +1515,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1492,6 +1540,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1519,6 +1568,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1550,6 +1600,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1581,6 +1632,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1598,6 +1650,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 30187cdcf2..b9761f4612 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -202,6 +203,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cfad296a9f..d73c37bd30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -186,6 +186,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1096da3502..1d4f3a5503 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -210,6 +218,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ade7c21f32..dd086fc539 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -20,6 +20,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5e09ce78a5..f21945c358 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -35,6 +35,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -51,6 +52,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -121,6 +123,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -168,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -260,6 +266,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -268,6 +275,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -412,6 +439,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -436,6 +464,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -453,6 +482,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -630,6 +660,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -667,6 +698,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 1a060ac1c9d3e02cf3d179219a2e17412763c6f7 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 273/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 524 ++++++++++++++++-- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 952 insertions(+), 93 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 8be1a7bc56..0e6b390f78 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,160 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +466,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -384,15 +523,6 @@ async fn ingest_turn_prerequisites( } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1347,7 +1477,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1408,7 +1538,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1433,6 +1563,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1491,6 +1841,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1504,6 +1855,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..2051fa60a1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bf97b36e31 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,14 +5,17 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,6 +23,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..69113bb5a9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -56,6 +67,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -102,6 +115,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +123,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +146,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +180,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +264,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +305,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +327,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +480,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +562,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +581,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +684,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +851,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +920,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..47f679350d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,23 +6904,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, /*error_type*/ None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,19 +6949,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6939,11 +6989,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7004,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7027,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From bcc8196c4f6bae64728241f056cc138deba64105 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 274/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 +++++++++++-------- codex-rs/analytics/src/reducer.rs | 16 ++++++++++---- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++++ codex-rs/core/src/codex.rs | 10 +++++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0e6b390f78..bcd8695cad 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -303,15 +303,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -467,6 +458,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 69113bb5a9..0404b2dfd8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -771,6 +771,12 @@ impl AnalyticsReducer { let Some((app_server_client, runtime)) = connection_metadata else { return; }; + let Some(thread_id) = turn_state.thread_id.as_ref() else { + return; + }; + let Some(thread_metadata) = self.thread_metadata.get(thread_id) else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", @@ -779,6 +785,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -791,6 +798,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -807,7 +815,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -830,10 +838,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 866c11c429..c7d61d96e6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9c29c56d8..7294ae473d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From 8dba615a8bec446c68b7a1ff63a8ab17311c8cf7 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 275/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 515 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 60 ++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/codex_tests_guardian.rs | 1 + codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1462 insertions(+), 61 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..326141e10b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,191 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1289,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..30187cdcf2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -184,6 +196,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -233,6 +251,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 97ebe1f046..cfad296a9f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -18,14 +22,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -39,6 +35,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -153,6 +150,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..1096da3502 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -154,6 +209,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..ade7c21f32 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,7 +16,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..5e09ce78a5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,9 +6,10 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -31,11 +32,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -44,6 +57,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -83,6 +98,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -102,17 +143,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -120,6 +165,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +222,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -274,24 +368,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -361,6 +555,161 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ce71e8974d..0b703542e5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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::AddCreditsNudgeEmailNotification; use codex_app_server_protocol::AddCreditsNudgeEmailResult; @@ -170,6 +171,7 @@ 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, @@ -205,6 +207,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -221,6 +227,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1756,6 +1763,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1933,6 +1941,7 @@ 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 { @@ -1947,6 +1956,10 @@ 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; @@ -2139,6 +2152,7 @@ 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>, ) { @@ -2159,6 +2173,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2168,6 +2183,7 @@ 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>, ) { @@ -2183,6 +2199,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2922,6 +2939,7 @@ 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; @@ -3043,6 +3061,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3057,6 +3076,7 @@ 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(), @@ -3368,6 +3388,13 @@ 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(), }; @@ -3776,6 +3803,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3824,6 +3852,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3871,6 +3900,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4138,6 +4168,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4159,6 +4190,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4170,6 +4202,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 795723f9d3..0e80289673 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6836,6 +6836,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7647,6 +7656,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9514,6 +9526,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0130966213..729c4a2db7 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -99,12 +99,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea0375b144..866c11c429 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +190,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -654,6 +657,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1171,6 +1175,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1195,6 +1200,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -2338,6 +2344,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6234,6 +6245,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6537,6 +6550,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 144d9a971b..a7e799a3ca 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -77,7 +77,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -96,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 66c116ee58..a9c29c56d8 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1991,6 +1991,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2096,6 +2097,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2451,6 +2453,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2714,6 +2717,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2818,6 +2822,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3663,6 +3668,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..55fe4ab040 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -455,6 +455,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e90dd0f80a..f7b2904659 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ce01994e4a..93c17bc03e 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 44693033d8..6fc203c8ab 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2453,6 +2453,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From e7c81347bfe8f562f5cb2241221d403a1fedfef0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 276/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 326141e10b..8be1a7bc56 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -285,6 +301,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -354,6 +371,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1328,6 +1356,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1384,6 +1417,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1406,6 +1444,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1455,6 +1494,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1468,6 +1515,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1492,6 +1540,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1519,6 +1568,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1550,6 +1600,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1581,6 +1632,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1598,6 +1650,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 30187cdcf2..b9761f4612 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -202,6 +203,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cfad296a9f..d73c37bd30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -186,6 +186,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1096da3502..1d4f3a5503 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -210,6 +218,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ade7c21f32..dd086fc539 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -20,6 +20,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5e09ce78a5..f21945c358 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -35,6 +35,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -51,6 +52,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -121,6 +123,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -168,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -260,6 +266,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -268,6 +275,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -412,6 +439,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -436,6 +464,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -453,6 +482,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -630,6 +660,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -667,6 +698,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From aa2b4665c7d204953dc58bc627774c1684a610cb Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 277/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 +++++++++++-------- codex-rs/analytics/src/reducer.rs | 16 ++++++++++---- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++++ codex-rs/core/src/codex.rs | 10 +++++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0e6b390f78..bcd8695cad 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -303,15 +303,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -467,6 +458,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 69113bb5a9..0404b2dfd8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -771,6 +771,12 @@ impl AnalyticsReducer { let Some((app_server_client, runtime)) = connection_metadata else { return; }; + let Some(thread_id) = turn_state.thread_id.as_ref() else { + return; + }; + let Some(thread_metadata) = self.thread_metadata.get(thread_id) else { + return; + }; out.push(TrackEventRequest::TurnEvent(Box::new( CodexTurnEventRequest { event_type: "codex_turn_event", @@ -779,6 +785,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -791,6 +798,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -807,7 +815,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -830,10 +838,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 866c11c429..c7d61d96e6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9c29c56d8..7294ae473d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From a1405eb1d7baad57bc47b9fdfbec43b6102518cc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 278/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 524 ++++++++++++++++-- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 952 insertions(+), 93 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 8be1a7bc56..0e6b390f78 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,160 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +466,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -384,15 +523,6 @@ async fn ingest_turn_prerequisites( } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1347,7 +1477,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1408,7 +1538,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1433,6 +1563,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1491,6 +1841,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1504,6 +1855,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..2051fa60a1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bf97b36e31 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,14 +5,17 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,6 +23,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..69113bb5a9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -56,6 +67,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -102,6 +115,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +123,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +146,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +180,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +264,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +305,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +327,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +480,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +562,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +581,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +684,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +851,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +920,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..47f679350d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,23 +6904,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, /*error_type*/ None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,19 +6949,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6939,11 +6989,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7004,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7027,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 169d8140063f8b771ad3f621c13cbd69b1b89f76 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 279/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 ++++++++------ codex-rs/analytics/src/reducer.rs | 28 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++ codex-rs/core/src/codex.rs | 10 +++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0e6b390f78..bcd8695cad 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -303,15 +303,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -467,6 +458,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 69113bb5a9..4b79a0f46f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -769,6 +769,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -779,6 +797,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -791,6 +810,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -807,7 +827,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -830,10 +850,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 866c11c429..c7d61d96e6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9c29c56d8..7294ae473d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From d91bd51ff9c7f02697479c474583b50f09b8e61e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 280/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 515 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 +- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 60 ++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1462 insertions(+), 62 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..326141e10b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,191 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1289,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..30187cdcf2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -184,6 +196,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -233,6 +251,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 97ebe1f046..cfad296a9f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -18,14 +22,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -39,6 +35,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -153,6 +150,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..1096da3502 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -154,6 +209,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..ade7c21f32 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,7 +16,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..5e09ce78a5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,9 +6,10 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -31,11 +32,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -44,6 +57,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -83,6 +98,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -102,17 +143,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -120,6 +165,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +222,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -274,24 +368,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -361,6 +555,161 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ce71e8974d..0b703542e5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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::AddCreditsNudgeEmailNotification; use codex_app_server_protocol::AddCreditsNudgeEmailResult; @@ -170,6 +171,7 @@ 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, @@ -205,6 +207,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -221,6 +227,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1756,6 +1763,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1933,6 +1941,7 @@ 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 { @@ -1947,6 +1956,10 @@ 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; @@ -2139,6 +2152,7 @@ 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>, ) { @@ -2159,6 +2173,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2168,6 +2183,7 @@ 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>, ) { @@ -2183,6 +2199,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2922,6 +2939,7 @@ 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; @@ -3043,6 +3061,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3057,6 +3076,7 @@ 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(), @@ -3368,6 +3388,13 @@ 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(), }; @@ -3776,6 +3803,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3824,6 +3852,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3871,6 +3900,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4138,6 +4168,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4159,6 +4190,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4170,6 +4202,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 795723f9d3..0e80289673 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6836,6 +6836,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7647,6 +7656,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9514,6 +9526,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0130966213..729c4a2db7 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -99,12 +99,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea0375b144..866c11c429 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +190,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -654,6 +657,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1171,6 +1175,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1195,6 +1200,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -2338,6 +2344,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6234,6 +6245,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6537,6 +6550,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 144d9a971b..a7e799a3ca 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -77,7 +77,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -96,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 66c116ee58..a9c29c56d8 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1991,6 +1991,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2096,6 +2097,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2451,6 +2453,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2714,6 +2717,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2818,6 +2822,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3663,6 +3668,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e90dd0f80a..f7b2904659 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ce01994e4a..93c17bc03e 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 44693033d8..6fc203c8ab 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2453,6 +2453,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From 9323523020a8f7584e4133dfd8ebc4c8283ecf3f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 281/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 326141e10b..8be1a7bc56 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -285,6 +301,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -354,6 +371,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn sample_runtime_metadata() -> CodexRuntimeMetadata { @@ -1328,6 +1356,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1384,6 +1417,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1406,6 +1444,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1455,6 +1494,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1468,6 +1515,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1492,6 +1540,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1519,6 +1568,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1550,6 +1600,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1581,6 +1632,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1598,6 +1650,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 30187cdcf2..b9761f4612 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -202,6 +203,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cfad296a9f..d73c37bd30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -186,6 +186,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1096da3502..1d4f3a5503 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -210,6 +218,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ade7c21f32..dd086fc539 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -20,6 +20,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5e09ce78a5..f21945c358 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -35,6 +35,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -51,6 +52,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -121,6 +123,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -168,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -260,6 +266,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -268,6 +275,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -412,6 +439,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -436,6 +464,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -453,6 +482,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -630,6 +660,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -667,6 +698,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From f00fe86b783d2fb37a425df87bd2c33d68f60b75 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 282/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 524 ++++++++++++++++-- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 952 insertions(+), 93 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 8be1a7bc56..0e6b390f78 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,160 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +466,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -384,15 +523,6 @@ async fn ingest_turn_prerequisites( } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1347,7 +1477,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1408,7 +1538,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1433,6 +1563,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1491,6 +1841,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1504,6 +1855,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..2051fa60a1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bf97b36e31 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,14 +5,17 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,6 +23,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..69113bb5a9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -56,6 +67,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -102,6 +115,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +123,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +146,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +180,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +264,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +305,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +327,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +480,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +562,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +581,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +684,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +851,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +920,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..47f679350d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,23 +6904,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, /*error_type*/ None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,19 +6949,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6939,11 +6989,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7004,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7027,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From f42998d8521238b7eabdb033ca1d079846e5153d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 13:36:46 -0700 Subject: [PATCH 283/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 ++++++++------ codex-rs/analytics/src/reducer.rs | 28 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++ codex-rs/core/src/codex.rs | 10 +++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0e6b390f78..bcd8695cad 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -303,15 +303,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -467,6 +458,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 69113bb5a9..4b79a0f46f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -769,6 +769,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -779,6 +797,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -791,6 +810,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -807,7 +827,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -830,10 +850,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 866c11c429..c7d61d96e6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9c29c56d8..7294ae473d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From 2e21934e10d28cd6ca1b4a45f0efc78e9787e77a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 14:38:04 -0700 Subject: [PATCH 284/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 506 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 ++- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 60 +++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1453 insertions(+), 62 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..46c5154061 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,182 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1280,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..30187cdcf2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -184,6 +196,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -233,6 +251,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 97ebe1f046..cfad296a9f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -18,14 +22,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -39,6 +35,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -153,6 +150,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..1096da3502 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -154,6 +209,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..ade7c21f32 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,7 +16,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..5e09ce78a5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,9 +6,10 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -31,11 +32,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -44,6 +57,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -83,6 +98,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -102,17 +143,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -120,6 +165,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +222,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -274,24 +368,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -361,6 +555,161 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ce71e8974d..0b703542e5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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::AddCreditsNudgeEmailNotification; use codex_app_server_protocol::AddCreditsNudgeEmailResult; @@ -170,6 +171,7 @@ 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, @@ -205,6 +207,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -221,6 +227,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1756,6 +1763,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1933,6 +1941,7 @@ 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 { @@ -1947,6 +1956,10 @@ 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; @@ -2139,6 +2152,7 @@ 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>, ) { @@ -2159,6 +2173,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2168,6 +2183,7 @@ 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>, ) { @@ -2183,6 +2199,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2922,6 +2939,7 @@ 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; @@ -3043,6 +3061,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3057,6 +3076,7 @@ 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(), @@ -3368,6 +3388,13 @@ 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(), }; @@ -3776,6 +3803,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3824,6 +3852,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3871,6 +3900,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4138,6 +4168,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4159,6 +4190,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4170,6 +4202,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 795723f9d3..0e80289673 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6836,6 +6836,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7647,6 +7656,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9514,6 +9526,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0130966213..729c4a2db7 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -99,12 +99,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea0375b144..866c11c429 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +190,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -654,6 +657,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1171,6 +1175,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1195,6 +1200,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -2338,6 +2344,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New => { // Defer initial context insertion until the first real turn starts so @@ -6234,6 +6245,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6537,6 +6550,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 144d9a971b..a7e799a3ca 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -77,7 +77,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -96,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 66c116ee58..a9c29c56d8 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1991,6 +1991,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2096,6 +2097,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2451,6 +2453,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2714,6 +2717,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2818,6 +2822,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3663,6 +3668,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e90dd0f80a..f7b2904659 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ce01994e4a..93c17bc03e 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 44693033d8..6fc203c8ab 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2453,6 +2453,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New => None, From b18388f2e65a05e93fbdb80ee364ca23bb4707b1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 14:38:25 -0700 Subject: [PATCH 285/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 515 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 952 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..0e6b390f78 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,160 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_runtime_metadata() -> CodexRuntimeMetadata { + CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + } +} + +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +466,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1477,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1538,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1563,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1841,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1855,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..2051fa60a1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bf97b36e31 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,14 +5,17 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,6 +23,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..69113bb5a9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -56,6 +67,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -102,6 +115,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +123,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +146,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +180,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +264,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +305,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +327,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +480,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +562,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +581,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +684,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +851,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +920,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..47f679350d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,23 +6904,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, /*error_type*/ None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,19 +6949,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6939,11 +6989,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7004,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7027,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From f71df46ada733cdb120ee5ccb91d30c329aa3187 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 14:38:25 -0700 Subject: [PATCH 286/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 ++++++++------ codex-rs/analytics/src/reducer.rs | 28 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++ codex-rs/core/src/codex.rs | 10 +++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0e6b390f78..bcd8695cad 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -303,15 +303,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } -fn sample_runtime_metadata() -> CodexRuntimeMetadata { - CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - } -} - fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -467,6 +458,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 69113bb5a9..4b79a0f46f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -769,6 +769,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -779,6 +797,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -791,6 +810,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -807,7 +827,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -830,10 +850,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 866c11c429..c7d61d96e6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9c29c56d8..7294ae473d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From 290c485ab572252325dc791238827551414a4256 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 14:38:25 -0700 Subject: [PATCH 287/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 46c5154061..97e1adcc1b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -285,6 +301,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -354,6 +371,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1319,6 +1347,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1375,6 +1408,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1397,6 +1435,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1446,6 +1485,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1459,6 +1506,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1483,6 +1531,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1510,6 +1559,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1541,6 +1591,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1572,6 +1623,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1589,6 +1641,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 30187cdcf2..b9761f4612 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -202,6 +203,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cfad296a9f..d73c37bd30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -186,6 +186,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1096da3502..1d4f3a5503 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -210,6 +218,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ade7c21f32..dd086fc539 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -20,6 +20,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5e09ce78a5..f21945c358 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -35,6 +35,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -51,6 +52,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -121,6 +123,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -168,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -260,6 +266,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -268,6 +275,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -412,6 +439,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -436,6 +464,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -453,6 +482,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -630,6 +660,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -667,6 +698,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 7021d18218fc18973bdde96777e428f17c552a4f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 15:07:52 -0700 Subject: [PATCH 288/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 506 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 943 insertions(+), 84 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..7de84ba10d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,151 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +457,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1468,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1529,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1554,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1832,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1846,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..2051fa60a1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bf97b36e31 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,14 +5,17 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,6 +23,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..69113bb5a9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -56,6 +67,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -102,6 +115,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +123,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +146,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +180,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +264,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +305,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +327,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +480,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +562,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +581,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +684,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +851,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +920,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..47f679350d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,23 +6904,31 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } }; if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "expectedTurnId must not be empty".to_string(), + data: None, + }; + self.track_error_response(&request_id, &error, /*error_type*/ None); + self.outgoing.send_error(request_id, error).await; return; } self.outgoing .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,19 +6949,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6939,11 +6989,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7004,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7027,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From c83f69b09e13e9939efad9fc7cbc56823dd34696 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 15:26:23 -0700 Subject: [PATCH 289/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 506 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 6 + codex-rs/analytics/src/reducer.rs | 236 +++++++- .../app-server/src/codex_message_processor.rs | 73 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 936 insertions(+), 79 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..7de84ba10d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,151 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +457,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1468,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1529,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1554,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1832,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1846,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..2051fa60a1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,8 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +95,53 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bf97b36e31 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,14 +5,17 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,6 +23,9 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..69113bb5a9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -56,6 +67,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -102,6 +115,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +123,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +146,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +180,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +264,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +305,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +327,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +480,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +562,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +581,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +684,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +851,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +920,46 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind, + })) => match turn_kind { + NonSteerableTurnKind::Review => Some(TurnSteerRejectionReason::NonSteerableReview), + NonSteerableTurnKind::Compact => Some(TurnSteerRejectionReason::NonSteerableCompact), + }, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..c217820dcb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,6 +6904,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6894,6 +6922,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,19 +6947,34 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { let message = match turn_kind { @@ -6939,11 +6987,9 @@ impl CodexMessageProcessor { }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7002,22 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }, + )), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7025,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 60c2461b824df7ce990cc7f9cb606a8b1506364e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 15:38:15 -0700 Subject: [PATCH 290/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 227 +++++++- .../app-server/src/codex_message_processor.rs | 81 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- 9 files changed, 940 insertions(+), 82 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..1acfdb92d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +455,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1466,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1527,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1552,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1830,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1844,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..3b1682c57c 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,7 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,54 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..a12c72ba83 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,16 +3,22 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,8 +26,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub(crate) fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..61627af6de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -102,6 +113,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +121,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +144,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +178,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +262,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +303,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +325,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +478,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +560,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +579,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +682,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +849,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +918,39 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview)) => { + Some(TurnSteerRejectionReason::NonSteerableReview) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableCompact)) => { + Some(TurnSteerRejectionReason::NonSteerableCompact) + } + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..f640b187cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,6 +6904,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6894,6 +6922,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,36 +6947,55 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { + let (message, turn_steer_error) = match turn_kind { codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() + ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ) } codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() + ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ) } }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7008,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7027,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} From 3a931e4a74d61865a8fca94182dfb233c82a1e45 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 15:42:00 -0700 Subject: [PATCH 291/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 227 +++++++- .../app-server/src/codex_message_processor.rs | 81 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 941 insertions(+), 91 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..1acfdb92d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +455,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1466,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1527,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1552,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1830,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1844,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..3b1682c57c 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,7 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,54 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bbfd78b04d 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,16 +3,22 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,8 +26,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..61627af6de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -102,6 +113,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +121,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +144,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +178,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +262,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +303,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +325,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +478,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +560,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +579,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +682,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +849,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +918,39 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview)) => { + Some(TurnSteerRejectionReason::NonSteerableReview) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableCompact)) => { + Some(TurnSteerRejectionReason::NonSteerableCompact) + } + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..f640b187cd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,6 +6904,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6894,6 +6922,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,36 +6947,55 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { + let (message, turn_steer_error) = match turn_kind { codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() + ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ) } codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() + ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ) } }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7008,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7027,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From b2b81bde9b07efba96d07cad86b98ea29ee48c50 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:00:03 -0700 Subject: [PATCH 292/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 227 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 941 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..1acfdb92d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +455,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1466,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1527,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1552,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1830,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1844,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..3b1682c57c 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,7 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,54 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +257,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bbfd78b04d 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,16 +3,22 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,8 +26,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..61627af6de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,31 +18,41 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerRequestError; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -102,6 +113,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +121,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +144,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +178,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +262,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +303,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +325,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +478,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +560,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +579,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +682,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +849,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +918,39 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type { + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn)) => { + Some(TurnSteerRejectionReason::NoActiveTurn) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::ExpectedTurnMismatch)) => { + Some(TurnSteerRejectionReason::ExpectedTurnMismatch) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview)) => { + Some(TurnSteerRejectionReason::NonSteerableReview) + } + Some(AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableCompact)) => { + Some(TurnSteerRejectionReason::NonSteerableCompact) + } + Some(AnalyticsJsonRpcError::Input(InputError::Empty)) => { + Some(TurnSteerRejectionReason::EmptyInput) + } + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)) => { + Some(TurnSteerRejectionReason::InputTooLarge) + } + None => None, + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..db4445897f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,6 +6904,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6894,6 +6922,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,36 +6947,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7004,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7023,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From e2cc7faac620018bfe30aecaf1a3f168b76c44b8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:00:26 -0700 Subject: [PATCH 293/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 12 ++++++++ codex-rs/analytics/src/reducer.rs | 28 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++ codex-rs/core/src/codex.rs | 10 +++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1acfdb92d3..80556a6f55 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -456,6 +456,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 61627af6de..51ac8958e0 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -767,6 +767,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -777,6 +795,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -789,6 +808,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -805,7 +825,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -828,10 +848,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 866c11c429..c7d61d96e6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9c29c56d8..7294ae473d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From c6c98c0fe23c418b6e822c5dff6fb23069380feb Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:00:03 -0700 Subject: [PATCH 294/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 942 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..1acfdb92d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +455,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1466,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1527,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1552,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1830,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1844,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..446186507a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,7 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +277,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bbfd78b04d 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,16 +3,22 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,8 +26,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..6edcbb0ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,15 +18,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -33,15 +37,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -102,6 +111,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +119,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +142,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +176,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +260,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +301,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +323,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +476,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +558,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +577,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +680,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +847,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +916,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0e80289673..db4445897f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -606,6 +609,22 @@ impl CodexMessageProcessor { ); } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6736,12 +6755,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6753,6 +6778,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6853,6 +6879,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6877,6 +6904,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6894,6 +6922,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6914,36 +6947,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6956,12 +7004,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6969,6 +7023,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From 4956c535a47ca1aba7e775b81f0c46cabe097ad2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:00:26 -0700 Subject: [PATCH 295/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 12 ++++++++ codex-rs/analytics/src/reducer.rs | 28 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++ codex-rs/core/src/codex.rs | 10 +++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1acfdb92d3..80556a6f55 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -456,6 +456,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 6edcbb0ccb..c636bfb37f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -765,6 +765,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -775,6 +793,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -787,6 +806,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -803,7 +823,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -826,10 +846,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 866c11c429..c7d61d96e6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index a9c29c56d8..7294ae473d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From 6a83f5548c46aa43db6c3b2adc9b44117c6ce923 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:53:38 -0700 Subject: [PATCH 296/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 506 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 ++- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 60 +++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests.rs | 6 + codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1453 insertions(+), 62 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..46c5154061 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,182 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1280,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..30187cdcf2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -184,6 +196,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -233,6 +251,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 97ebe1f046..cfad296a9f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -18,14 +22,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -39,6 +35,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -153,6 +150,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..1096da3502 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -154,6 +209,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..ade7c21f32 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,7 +16,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..5e09ce78a5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,9 +6,10 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -31,11 +32,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -44,6 +57,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -83,6 +98,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -102,17 +143,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -120,6 +165,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +222,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -274,24 +368,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -361,6 +555,161 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d8990483b7..53361bc3c3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1738,6 +1745,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1915,6 +1923,7 @@ 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 { @@ -1929,6 +1938,10 @@ 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; @@ -2121,6 +2134,7 @@ 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>, ) { @@ -2141,6 +2155,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2150,6 +2165,7 @@ 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>, ) { @@ -2165,6 +2181,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2905,6 +2922,7 @@ 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; @@ -3032,6 +3050,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3046,6 +3065,7 @@ 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(), @@ -3372,6 +3392,13 @@ 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(), }; @@ -3822,6 +3849,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3870,6 +3898,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3917,6 +3946,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4183,6 +4213,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4204,6 +4235,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4215,6 +4247,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e0509907b9..143512f44c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6669,6 +6669,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7480,6 +7489,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9347,6 +9359,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..69d209c020 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cd6f1e84fd..ad171ef98f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +190,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -654,6 +657,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1171,6 +1175,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1195,6 +1200,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -2339,6 +2345,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New | InitialHistory::Cleared => { // Defer initial context insertion until the first real turn starts so @@ -6187,6 +6198,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6490,6 +6503,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4deb7d3cfd..8257369048 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -78,7 +78,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -97,6 +96,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6e901a0346..08c9af6fb7 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1991,6 +1991,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2093,6 +2094,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2445,6 +2447,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2708,6 +2711,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2812,6 +2816,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3657,6 +3662,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e90dd0f80a..f7b2904659 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 62d43893fe..4b039faac8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2430,6 +2430,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New | InitialHistory::Cleared => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New | InitialHistory::Cleared => None, From 2a16ce9e0d8a157992b67a9faf1e0da3a670dcf7 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:53:38 -0700 Subject: [PATCH 297/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 46c5154061..97e1adcc1b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -285,6 +301,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -354,6 +371,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1319,6 +1347,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1375,6 +1408,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1397,6 +1435,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1446,6 +1485,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1459,6 +1506,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1483,6 +1531,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1510,6 +1559,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1541,6 +1591,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1572,6 +1623,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1589,6 +1641,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 30187cdcf2..b9761f4612 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -202,6 +203,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cfad296a9f..d73c37bd30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -186,6 +186,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1096da3502..1d4f3a5503 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -210,6 +218,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ade7c21f32..dd086fc539 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -20,6 +20,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5e09ce78a5..f21945c358 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -35,6 +35,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -51,6 +52,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -121,6 +123,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -168,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -260,6 +266,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -268,6 +275,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -412,6 +439,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -436,6 +464,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -453,6 +482,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -630,6 +660,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -667,6 +698,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From ba4058c9162e3248f786b478205b91e0953cb2b3 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:53:38 -0700 Subject: [PATCH 298/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 942 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..1acfdb92d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +455,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1466,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1527,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1552,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1830,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1844,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..446186507a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,7 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +277,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bbfd78b04d 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,16 +3,22 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,8 +26,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..6edcbb0ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,15 +18,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -33,15 +37,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -102,6 +111,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +119,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +142,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +176,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +260,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +301,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +323,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +476,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +558,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +577,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +680,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +847,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +916,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 143512f44c..4159827e25 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -504,6 +507,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6569,12 +6588,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6586,6 +6611,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6686,6 +6712,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6710,6 +6737,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6727,6 +6755,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6747,36 +6780,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6789,12 +6837,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6802,6 +6856,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From 65767d64185d5a5cd48b2f30f8fff77606070f67 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 16:53:38 -0700 Subject: [PATCH 299/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 12 ++++++++ codex-rs/analytics/src/reducer.rs | 28 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++++ codex-rs/core/src/codex.rs | 10 +++++++ codex-rs/core/src/codex_tests.rs | 1 + 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1acfdb92d3..80556a6f55 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -456,6 +456,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 6edcbb0ccb..c636bfb37f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -765,6 +765,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -775,6 +793,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -787,6 +806,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -803,7 +823,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, initialization_mode, model, model_provider, @@ -826,10 +846,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ad171ef98f..422d7afe64 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1094,6 +1094,14 @@ impl TurnContext { } } +fn thread_initialization_mode(initial_history: &InitialHistory) -> ThreadInitializationMode { + match initial_history { + InitialHistory::New | InitialHistory::Cleared => ThreadInitializationMode::New, + InitialHistory::Forked(_) => ThreadInitializationMode::Forked, + InitialHistory::Resumed(_) => ThreadInitializationMode::Resumed, + } +} + fn local_time_context() -> (String, String) { match iana_time_zone::get_timezone() { Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), @@ -1622,6 +1630,8 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + session_configuration.thread_initialization_mode = + thread_initialization_mode(&initial_history); let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 08c9af6fb7..813ccef225 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,7 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; From 9c6c06bb25e1211e9485d7ad252ebc6eb8dfa785 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:11:00 -0700 Subject: [PATCH 300/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 505 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 ++- codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 396 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 55 ++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 26 files changed, 1437 insertions(+), 62 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..987d6e80df 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,181 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1279,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..30187cdcf2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -184,6 +196,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -233,6 +251,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 97ebe1f046..cfad296a9f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -18,14 +22,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -39,6 +35,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -153,6 +150,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..b53c5aa9af 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,51 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -154,6 +208,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..ade7c21f32 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,7 +16,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..492a4d017e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,9 +6,10 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -31,11 +32,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -44,6 +57,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -83,6 +98,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -102,17 +143,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -120,6 +165,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +222,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -274,24 +368,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -361,6 +555,160 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d8990483b7..53361bc3c3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1738,6 +1745,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1915,6 +1923,7 @@ 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 { @@ -1929,6 +1938,10 @@ 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; @@ -2121,6 +2134,7 @@ 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>, ) { @@ -2141,6 +2155,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2150,6 +2165,7 @@ 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>, ) { @@ -2165,6 +2181,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2905,6 +2922,7 @@ 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; @@ -3032,6 +3050,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3046,6 +3065,7 @@ 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(), @@ -3372,6 +3392,13 @@ 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(), }; @@ -3822,6 +3849,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3870,6 +3898,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3917,6 +3946,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4183,6 +4213,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4204,6 +4235,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4215,6 +4247,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e0509907b9..143512f44c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6669,6 +6669,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7480,6 +7489,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9347,6 +9359,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..69d209c020 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cd6f1e84fd..49588490ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,7 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +189,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -2339,6 +2341,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New | InitialHistory::Cleared => { // Defer initial context insertion until the first real turn starts so @@ -6187,6 +6194,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6490,6 +6499,52 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4deb7d3cfd..8257369048 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -78,7 +78,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -97,6 +96,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 62d43893fe..4b039faac8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2430,6 +2430,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New | InitialHistory::Cleared => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New | InitialHistory::Cleared => None, From 24c819097ca00a07079bbd059baba6f403aee5a5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:11:00 -0700 Subject: [PATCH 301/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 987d6e80df..cc6b50338e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -284,6 +300,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -353,6 +370,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1318,6 +1346,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1374,6 +1407,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1396,6 +1434,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1445,6 +1484,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1458,6 +1505,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1482,6 +1530,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1509,6 +1558,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1540,6 +1590,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1571,6 +1622,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1588,6 +1640,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 30187cdcf2..b9761f4612 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -202,6 +203,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cfad296a9f..d73c37bd30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -186,6 +186,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b53c5aa9af..d32c2375c7 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -76,6 +77,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -209,6 +217,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ade7c21f32..dd086fc539 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -20,6 +20,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 492a4d017e..7baba6f3ad 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -35,6 +35,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -51,6 +52,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -121,6 +123,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -168,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -260,6 +266,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -268,6 +275,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -412,6 +439,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -436,6 +464,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -453,6 +482,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -629,6 +659,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -666,6 +697,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 3519f14bf793dec03d2752ee9d085a5ab76dd56b Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:11:00 -0700 Subject: [PATCH 302/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 942 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index cc6b50338e..b6f9d8aa3b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -294,6 +302,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -303,30 +454,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1337,7 +1465,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1398,7 +1526,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1423,6 +1551,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1481,6 +1829,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1494,6 +1843,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d32c2375c7..b7f34ba803 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,7 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -92,6 +93,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -207,6 +276,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bbfd78b04d 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,16 +3,22 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,8 +26,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7baba6f3ad..d49c88e483 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,15 +18,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -33,15 +37,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -102,6 +111,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +119,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +142,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +176,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +260,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +301,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +323,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +476,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +558,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +577,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +680,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -688,7 +846,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -757,6 +915,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 143512f44c..4159827e25 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -504,6 +507,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6569,12 +6588,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6586,6 +6611,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6686,6 +6712,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6710,6 +6737,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6727,6 +6755,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6747,36 +6780,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6789,12 +6837,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6802,6 +6856,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From 7538889d4a20f063489e38015be59e9f1c995ca8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:11:48 -0700 Subject: [PATCH 303/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 12 ++++++ codex-rs/analytics/src/reducer.rs | 38 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b6f9d8aa3b..32cfb36aa5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d49c88e483..d88a4f9bb2 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -83,12 +83,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -103,6 +107,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -615,7 +620,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -765,6 +771,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -775,6 +799,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -787,6 +812,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -803,7 +829,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, model, model_provider, sandbox_policy, @@ -825,10 +851,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode: thread_metadata.initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From 963ce10299a11f3526e8c6a64c12ea472d2feb66 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:21:06 -0700 Subject: [PATCH 304/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 20 ++++++++ codex-rs/analytics/src/events.rs | 12 +++++ codex-rs/analytics/src/reducer.rs | 49 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++ 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b6f9d8aa3b..a89e684aa2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1610,6 +1622,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1638,6 +1654,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4bc3ccde9d..74105a742d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -213,6 +213,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -351,6 +355,10 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, + subagent_source: Option, + parent_thread_id: Option, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -359,6 +367,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_source.map(str::to_string), + initialization_mode, + subagent_source, + parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d49c88e483..00e4b7abd7 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -83,12 +83,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -103,6 +107,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -615,7 +620,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -717,6 +723,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -739,6 +752,10 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + thread_metadata.thread_source, + thread_metadata.initialization_mode, + thread_metadata.subagent_source.clone(), + thread_metadata.parent_thread_id.clone(), turn_steer, ), })); @@ -765,6 +782,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -775,6 +810,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -787,6 +823,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -803,7 +840,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, + session_source: _session_source, model, model_provider, sandbox_policy, @@ -825,10 +862,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), + thread_source: thread_metadata.thread_source.map(str::to_string), initialization_mode: thread_metadata.initialization_mode, - subagent_source: None, - parent_thread_id: None, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From 61d744280093935dbdb6e6901f717803baae0a48 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:28:27 -0700 Subject: [PATCH 305/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 506 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 ++- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 60 +++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests.rs | 8 + codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1455 insertions(+), 62 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..46c5154061 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,182 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1280,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..30187cdcf2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -13,9 +13,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -160,6 +164,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -184,6 +196,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -233,6 +251,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 97ebe1f046..cfad296a9f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SessionSource; @@ -18,14 +22,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -39,6 +35,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -153,6 +150,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..1096da3502 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -6,6 +6,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -30,6 +39,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -154,6 +209,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..ade7c21f32 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -16,7 +16,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..5e09ce78a5 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,9 +6,10 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -31,11 +32,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -44,6 +57,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -83,6 +98,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -102,17 +143,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -120,6 +165,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +222,52 @@ impl AnalyticsReducer { )); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -274,24 +368,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -361,6 +555,161 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d8990483b7..53361bc3c3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1738,6 +1745,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1915,6 +1923,7 @@ 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 { @@ -1929,6 +1938,10 @@ 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; @@ -2121,6 +2134,7 @@ 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>, ) { @@ -2141,6 +2155,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2150,6 +2165,7 @@ 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>, ) { @@ -2165,6 +2181,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2905,6 +2922,7 @@ 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; @@ -3032,6 +3050,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3046,6 +3065,7 @@ 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(), @@ -3372,6 +3392,13 @@ 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(), }; @@ -3822,6 +3849,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3870,6 +3898,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3917,6 +3946,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4183,6 +4213,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4204,6 +4235,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4215,6 +4247,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e0509907b9..143512f44c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6669,6 +6669,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7480,6 +7489,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9347,6 +9359,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..69d209c020 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cd6f1e84fd..ad171ef98f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +190,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -654,6 +657,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1171,6 +1175,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1195,6 +1200,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -2339,6 +2345,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New | InitialHistory::Cleared => { // Defer initial context insertion until the first real turn starts so @@ -6187,6 +6198,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6490,6 +6503,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4deb7d3cfd..8257369048 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -78,7 +78,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -97,6 +96,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6e901a0346..ae444b4975 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,8 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; + use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1991,6 +1993,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2093,6 +2096,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2445,6 +2449,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2708,6 +2713,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2812,6 +2818,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3657,6 +3664,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e90dd0f80a..f7b2904659 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 62d43893fe..4b039faac8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2430,6 +2430,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New | InitialHistory::Cleared => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New | InitialHistory::Cleared => None, From 610484b127798ed1936e36c41181adeaba3ca0b6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:28:49 -0700 Subject: [PATCH 306/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 46c5154061..97e1adcc1b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -285,6 +301,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -354,6 +371,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1319,6 +1347,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1375,6 +1408,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1397,6 +1435,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1446,6 +1485,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1459,6 +1506,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1483,6 +1531,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1510,6 +1559,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1541,6 +1591,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1572,6 +1623,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1589,6 +1641,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 30187cdcf2..b9761f4612 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,6 +14,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -202,6 +203,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cfad296a9f..d73c37bd30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -186,6 +186,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1096da3502..1d4f3a5503 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -210,6 +218,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ade7c21f32..dd086fc539 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -20,6 +20,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5e09ce78a5..f21945c358 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -35,6 +35,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -51,6 +52,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -121,6 +123,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -168,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -260,6 +266,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -268,6 +275,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -412,6 +439,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -436,6 +464,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -453,6 +482,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -630,6 +660,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -667,6 +698,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From b0699da049610b4ec3bc4e036333d1ce7d67ce65 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:28:49 -0700 Subject: [PATCH 307/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 942 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..1acfdb92d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +455,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1466,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1527,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1552,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1830,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1844,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b9761f4612..0cc7018269 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -3,6 +3,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -19,6 +20,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -259,6 +261,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d73c37bd30..4bc3ccde9d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -36,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -202,6 +206,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -324,6 +347,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d4f3a5503..446186507a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -3,6 +3,7 @@ use crate::events::CodexRuntimeMetadata; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +277,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index dd086fc539..bbfd78b04d 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,16 +3,22 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -20,8 +26,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f21945c358..6edcbb0ccb 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; @@ -17,15 +18,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -33,15 +37,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -102,6 +111,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -109,6 +119,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -125,6 +142,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -158,6 +176,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -234,22 +260,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -268,6 +301,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -289,6 +323,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -441,16 +476,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -466,6 +558,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -484,6 +577,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -586,6 +680,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -689,7 +847,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -758,6 +916,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 143512f44c..4159827e25 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -504,6 +507,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6569,12 +6588,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6586,6 +6611,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6686,6 +6712,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6710,6 +6737,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6727,6 +6755,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6747,36 +6780,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6789,12 +6837,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6802,6 +6856,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From f45761f5930ce44d93e1105cb9d5e29764d0fbc1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:37:12 -0700 Subject: [PATCH 308/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 +++++++- codex-rs/analytics/src/events.rs | 12 +++++ codex-rs/analytics/src/facts.rs | 1 - codex-rs/analytics/src/reducer.rs | 52 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++ codex-rs/core/src/codex.rs | 5 -- codex-rs/core/src/codex_tests.rs | 8 --- codex-rs/core/src/codex_thread.rs | 2 - 8 files changed, 87 insertions(+), 25 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1acfdb92d3..a89e684aa2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -287,7 +287,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { submission_type: None, ephemeral: false, session_source: SessionSource::Exec, - initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -456,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1611,6 +1622,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1639,6 +1654,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4bc3ccde9d..74105a742d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -213,6 +213,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -351,6 +355,10 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, + subagent_source: Option, + parent_thread_id: Option, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -359,6 +367,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_source.map(str::to_string), + initialization_mode, + subagent_source, + parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 446186507a..b7f34ba803 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -56,7 +56,6 @@ pub struct TurnResolvedConfigFact { pub submission_type: Option, pub ephemeral: bool, pub session_source: SessionSource, - pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 6edcbb0ccb..00e4b7abd7 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -83,12 +83,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -103,6 +107,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -615,7 +620,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -717,6 +723,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -739,6 +752,10 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + thread_metadata.thread_source, + thread_metadata.initialization_mode, + thread_metadata.subagent_source.clone(), + thread_metadata.parent_thread_id.clone(), turn_steer, ), })); @@ -765,6 +782,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -775,6 +810,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -787,6 +823,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -803,8 +840,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, - initialization_mode, + session_source: _session_source, model, model_provider, sandbox_policy, @@ -826,10 +862,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), - initialization_mode, - subagent_source: None, - parent_thread_id: None, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ad171ef98f..49588490ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,7 +55,6 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; -use codex_analytics::ThreadInitializationMode; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; @@ -657,7 +656,6 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1175,7 +1173,6 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, - thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1200,7 +1197,6 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), - initialization_mode: self.thread_initialization_mode, } } @@ -6534,7 +6530,6 @@ async fn track_turn_resolved_config_analytics( submission_type: None, ephemeral: thread_config.ephemeral, session_source: thread_config.session_source, - initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae444b4975..6e901a0346 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,8 +15,6 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; -use codex_analytics::ThreadInitializationMode; - use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1993,7 +1991,6 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2096,7 +2093,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2449,7 +2445,6 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2713,7 +2708,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2818,7 +2812,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3664,7 +3657,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f7b2904659..e90dd0f80a 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,7 +3,6 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; -use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -44,7 +43,6 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, - pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From ccb9b34300cf75b19df703b3e5672d63848aac4b Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:46:43 -0700 Subject: [PATCH 309/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 506 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 60 ++- codex-rs/analytics/src/facts.rs | 56 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 397 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 +++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 60 +++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests.rs | 8 + codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 28 files changed, 1455 insertions(+), 62 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..46c5154061 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,182 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + initialization_mode: ThreadInitializationMode::New, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1087,6 +1280,317 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 0d96ee6061..bd4cfdd473 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,9 +14,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -167,6 +171,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -191,6 +203,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -240,6 +258,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 618dd8ffeb..891e52f563 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -21,14 +25,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -43,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -330,6 +327,53 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 931ae01013..d6b97c65bc 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -7,6 +7,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -31,6 +40,52 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -156,6 +211,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), GuardianReview(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 9b4cc1e9bc..acc88acf5e 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -27,7 +27,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 0ed2899762..5be5056190 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,12 +6,13 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,11 +35,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -47,6 +60,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -86,6 +101,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -105,17 +146,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -126,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::GuardianReview(input) => { self.ingest_guardian_review(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -216,6 +264,52 @@ impl AnalyticsReducer { ))); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -316,24 +410,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -403,6 +597,161 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source, + initialization_mode, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + thread_source: thread_source_name(&session_source).map(str::to_string), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d8990483b7..53361bc3c3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1738,6 +1745,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1915,6 +1923,7 @@ 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 { @@ -1929,6 +1938,10 @@ 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; @@ -2121,6 +2134,7 @@ 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>, ) { @@ -2141,6 +2155,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2150,6 +2165,7 @@ 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>, ) { @@ -2165,6 +2181,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2905,6 +2922,7 @@ 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; @@ -3032,6 +3050,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3046,6 +3065,7 @@ 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(), @@ -3372,6 +3392,13 @@ 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(), }; @@ -3822,6 +3849,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3870,6 +3898,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3917,6 +3946,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4183,6 +4213,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4204,6 +4235,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4215,6 +4247,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e0509907b9..143512f44c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6669,6 +6669,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7480,6 +7489,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9347,6 +9359,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..69d209c020 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cd6f1e84fd..ad171ef98f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,8 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::ThreadInitializationMode; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +190,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -654,6 +657,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1171,6 +1175,7 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1195,6 +1200,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + initialization_mode: self.thread_initialization_mode, } } @@ -2339,6 +2345,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New | InitialHistory::Cleared => { // Defer initial context insertion until the first real turn starts so @@ -6187,6 +6198,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6490,6 +6503,53 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + initialization_mode: thread_config.initialization_mode, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4deb7d3cfd..8257369048 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -78,7 +78,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -97,6 +96,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6e901a0346..ae444b4975 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,6 +15,8 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; +use codex_analytics::ThreadInitializationMode; + use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1991,6 +1993,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2093,6 +2096,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2445,6 +2449,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2708,6 +2713,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2812,6 +2818,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3657,6 +3664,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e90dd0f80a..f7b2904659 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,6 +3,7 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -43,6 +44,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2c2c9cabec..fc20a527cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2430,6 +2430,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New | InitialHistory::Cleared => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New | InitialHistory::Cleared => None, From f277d5c99abc68f67c8d0cf656aae93b3d1c308c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:46:43 -0700 Subject: [PATCH 310/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 46c5154061..97e1adcc1b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -285,6 +301,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -354,6 +371,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1319,6 +1347,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1375,6 +1408,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1397,6 +1435,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1446,6 +1485,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1459,6 +1506,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1483,6 +1531,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1510,6 +1559,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1541,6 +1591,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1572,6 +1623,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1589,6 +1641,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index bd4cfdd473..8524ddc1e2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -15,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -209,6 +210,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 891e52f563..55eedb90d7 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -363,6 +363,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d6b97c65bc..fd199b6233 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -18,6 +18,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -78,6 +79,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -212,6 +220,7 @@ pub(crate) enum CustomAnalyticsFact { Compaction(Box), GuardianReview(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index acc88acf5e..a93f1f8051 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -31,6 +31,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5be5056190..afa9719a67 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -38,6 +38,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -54,6 +55,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -124,6 +126,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -174,6 +177,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -302,6 +308,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -310,6 +317,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -454,6 +481,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -478,6 +506,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -495,6 +524,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -672,6 +702,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -709,6 +740,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From 4edc72843f76e75da07941decf59aaa516c59a3b Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:47:59 -0700 Subject: [PATCH 311/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 942 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 97e1adcc1b..1acfdb92d3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -295,6 +303,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -304,30 +455,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1338,7 +1466,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1399,7 +1527,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1424,6 +1552,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1482,6 +1830,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1495,6 +1844,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 8524ddc1e2..5ba60e3ef4 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -4,6 +4,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -20,6 +21,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -266,6 +268,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 55eedb90d7..ba0dc9a06c 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -40,6 +43,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -379,6 +383,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -501,6 +524,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index fd199b6233..c1107c72cf 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -4,6 +4,7 @@ use crate::events::GuardianReviewEventParams; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -94,6 +95,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -209,6 +278,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a93f1f8051..1a1a123152 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,6 +3,9 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; @@ -16,14 +19,17 @@ pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -31,8 +37,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index afa9719a67..2a76a9aba0 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -20,15 +21,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -36,15 +40,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -105,6 +114,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -112,6 +122,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -128,6 +145,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -161,6 +179,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -276,22 +302,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -310,6 +343,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -331,6 +365,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -483,16 +518,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -508,6 +600,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -526,6 +619,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -628,6 +722,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -731,7 +889,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -800,6 +958,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 143512f44c..4159827e25 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -504,6 +507,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6569,12 +6588,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6586,6 +6611,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6686,6 +6712,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6710,6 +6737,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6727,6 +6755,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6747,36 +6780,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6789,12 +6837,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6802,6 +6856,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From 1053b017c6105be315ed8d4b8cea78cd50f7d825 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 17:47:59 -0700 Subject: [PATCH 312/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 +++++++- codex-rs/analytics/src/events.rs | 12 +++++ codex-rs/analytics/src/facts.rs | 1 - codex-rs/analytics/src/reducer.rs | 52 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++ codex-rs/core/src/codex.rs | 5 -- codex-rs/core/src/codex_tests.rs | 8 --- codex-rs/core/src/codex_thread.rs | 2 - 8 files changed, 87 insertions(+), 25 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1acfdb92d3..a89e684aa2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -287,7 +287,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { submission_type: None, ephemeral: false, session_source: SessionSource::Exec, - initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -456,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1611,6 +1622,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1639,6 +1654,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ba0dc9a06c..933848c8e8 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -390,6 +390,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -528,6 +532,10 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, + subagent_source: Option, + parent_thread_id: Option, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -536,6 +544,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_source.map(str::to_string), + initialization_mode, + subagent_source, + parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index c1107c72cf..5590fbfa6d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -57,7 +57,6 @@ pub struct TurnResolvedConfigFact { pub submission_type: Option, pub ephemeral: bool, pub session_source: SessionSource, - pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 2a76a9aba0..2f02afca3e 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -86,12 +86,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +110,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +662,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,6 +765,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -781,6 +794,10 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + thread_metadata.thread_source, + thread_metadata.initialization_mode, + thread_metadata.subagent_source.clone(), + thread_metadata.parent_thread_id.clone(), turn_steer, ), })); @@ -807,6 +824,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +852,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +865,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -845,8 +882,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, - initialization_mode, + session_source: _session_source, model, model_provider, sandbox_policy, @@ -868,10 +904,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), - initialization_mode, - subagent_source: None, - parent_thread_id: None, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ad171ef98f..49588490ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,7 +55,6 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; -use codex_analytics::ThreadInitializationMode; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; @@ -657,7 +656,6 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1175,7 +1173,6 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, - thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1200,7 +1197,6 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), - initialization_mode: self.thread_initialization_mode, } } @@ -6534,7 +6530,6 @@ async fn track_turn_resolved_config_analytics( submission_type: None, ephemeral: thread_config.ephemeral, session_source: thread_config.session_source, - initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae444b4975..6e901a0346 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,8 +15,6 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; -use codex_analytics::ThreadInitializationMode; - use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1993,7 +1991,6 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2096,7 +2093,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2449,7 +2445,6 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2713,7 +2708,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2818,7 +2812,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3664,7 +3657,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f7b2904659..e90dd0f80a 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,7 +3,6 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; -use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -44,7 +43,6 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, - pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From dbf2dc1743c9f2a44fc1bee2fe35db41ae65f516 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:05:01 -0700 Subject: [PATCH 313/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 21 ++++++- codex-rs/analytics/src/events.rs | 16 ++++++ codex-rs/analytics/src/facts.rs | 1 - codex-rs/analytics/src/reducer.rs | 55 ++++++++++++++++--- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++ codex-rs/core/src/codex.rs | 5 -- codex-rs/core/src/codex_tests.rs | 8 --- codex-rs/core/src/codex_thread.rs | 2 - 8 files changed, 94 insertions(+), 25 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 1acfdb92d3..a89e684aa2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -287,7 +287,6 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { submission_type: None, ephemeral: false, session_source: SessionSource::Exec, - initialization_mode: ThreadInitializationMode::New, model: "gpt-5".to_string(), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -456,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1611,6 +1622,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1639,6 +1654,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ba0dc9a06c..ea60a41b4b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -86,6 +86,13 @@ pub(crate) struct CodexRuntimeMetadata { pub(crate) runtime_arch: String, } +pub(crate) struct ThreadLifecycleMetadata { + pub(crate) thread_source: Option<&'static str>, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, +} + #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, @@ -390,6 +397,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -528,6 +539,7 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_metadata: ThreadLifecycleMetadata, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -536,6 +548,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source, + parent_thread_id: thread_metadata.parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index c1107c72cf..5590fbfa6d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -57,7 +57,6 @@ pub struct TurnResolvedConfigFact { pub submission_type: Option, pub ephemeral: bool, pub session_source: SessionSource, - pub initialization_mode: ThreadInitializationMode, pub model: String, pub model_provider: String, pub sandbox_policy: SandboxPolicy, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 2a76a9aba0..531354a2d0 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -16,6 +16,7 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ThreadLifecycleMetadata; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; @@ -86,12 +87,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +111,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +663,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,6 +766,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -781,6 +795,12 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + ThreadLifecycleMetadata { + thread_source: thread_metadata.thread_source, + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + }, turn_steer, ), })); @@ -807,6 +827,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +855,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +868,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -845,8 +885,7 @@ fn codex_turn_event_params( num_input_images: _resolved_num_input_images, submission_type, ephemeral, - session_source, - initialization_mode, + session_source: _session_source, model, model_provider, sandbox_policy, @@ -868,10 +907,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_source_name(&session_source).map(str::to_string), - initialization_mode, - subagent_source: None, - parent_thread_id: None, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ad171ef98f..49588490ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,7 +55,6 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; -use codex_analytics::ThreadInitializationMode; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; @@ -657,7 +656,6 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -1175,7 +1173,6 @@ pub(crate) struct SessionConfiguration { app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, - thread_initialization_mode: ThreadInitializationMode, dynamic_tools: Vec, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1200,7 +1197,6 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), - initialization_mode: self.thread_initialization_mode, } } @@ -6534,7 +6530,6 @@ async fn track_turn_resolved_config_analytics( submission_type: None, ephemeral: thread_config.ephemeral, session_source: thread_config.session_source, - initialization_mode: thread_config.initialization_mode, model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), sandbox_policy: turn_context.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ae444b4975..6e901a0346 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -15,8 +15,6 @@ use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; -use codex_analytics::ThreadInitializationMode; - use codex_features::Features; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1993,7 +1991,6 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2096,7 +2093,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2449,7 +2445,6 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2713,7 +2708,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2818,7 +2812,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3664,7 +3657,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, - thread_initialization_mode: ThreadInitializationMode::New, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f7b2904659..e90dd0f80a 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -3,7 +3,6 @@ use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; -use codex_analytics::ThreadInitializationMode; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; @@ -44,7 +43,6 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, - pub initialization_mode: ThreadInitializationMode, } pub struct CodexThread { From 0b05e128e0736e8d9b76a49e7d15443c571b41df Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:31:23 -0700 Subject: [PATCH 314/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 497 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 56 +- codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 392 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 13 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 ++++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 55 ++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 26 files changed, 1417 insertions(+), 66 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..0720b47b88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,181 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -563,10 +755,6 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize payload[0]["event_params"]["runtime"]["runtime_arch"], "x86_64" ); - assert_eq!(payload[0]["event_params"]["initialization_mode"], "resumed"); - assert_eq!(payload[0]["event_params"]["thread_source"], "user"); - assert_eq!(payload[0]["event_params"]["subagent_source"], json!(null)); - assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } #[tokio::test] @@ -1087,6 +1275,305 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 0d96ee6061..bd4cfdd473 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,9 +14,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -167,6 +171,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -191,6 +203,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -240,6 +258,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 618dd8ffeb..4e073c123e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -21,14 +25,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -43,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -330,6 +327,49 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 931ae01013..a36ba125a5 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -7,6 +7,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -31,6 +40,51 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -156,6 +210,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), GuardianReview(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 9b4cc1e9bc..acc88acf5e 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -27,7 +27,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 0ed2899762..e52b7e7d3b 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,12 +6,13 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,11 +35,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -47,6 +60,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -86,6 +101,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -105,17 +146,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -126,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::GuardianReview(input) => { self.ingest_guardian_review(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -216,6 +264,52 @@ impl AnalyticsReducer { ))); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -316,24 +410,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -403,6 +597,156 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source: _session_source, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d8990483b7..53361bc3c3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1738,6 +1745,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1915,6 +1923,7 @@ 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 { @@ -1929,6 +1938,10 @@ 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; @@ -2121,6 +2134,7 @@ 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>, ) { @@ -2141,6 +2155,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2150,6 +2165,7 @@ 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>, ) { @@ -2165,6 +2181,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2905,6 +2922,7 @@ 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; @@ -3032,6 +3050,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3046,6 +3065,7 @@ 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(), @@ -3372,6 +3392,13 @@ 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(), }; @@ -3822,6 +3849,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3870,6 +3898,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3917,6 +3946,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4183,6 +4213,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4204,6 +4235,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4215,6 +4247,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e0509907b9..143512f44c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6669,6 +6669,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7480,6 +7489,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -9347,6 +9359,7 @@ mod tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + initialization_mode: codex_analytics::ThreadInitializationMode::New, }; assert_eq!( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..69d209c020 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cd6f1e84fd..49588490ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,7 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +189,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -2339,6 +2341,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New | InitialHistory::Cleared => { // Defer initial context insertion until the first real turn starts so @@ -6187,6 +6194,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6490,6 +6499,52 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4deb7d3cfd..8257369048 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -78,7 +78,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -97,6 +96,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2c2c9cabec..fc20a527cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2430,6 +2430,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New | InitialHistory::Cleared => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New | InitialHistory::Cleared => None, From 4164022f6aa064a693c3804c4a8ebe26f24178f2 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:34:52 -0700 Subject: [PATCH 315/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0720b47b88..68874ff1f3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -284,6 +300,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -353,6 +370,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1310,6 +1338,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1362,6 +1395,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1384,6 +1422,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1429,6 +1468,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1442,6 +1489,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1466,6 +1514,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1493,6 +1542,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1524,6 +1574,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1555,6 +1606,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1572,6 +1624,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index bd4cfdd473..8524ddc1e2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -15,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -209,6 +210,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4e073c123e..ce2745cd67 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -359,6 +359,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a36ba125a5..2ed0fba7ff 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -18,6 +18,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -211,6 +219,7 @@ pub(crate) enum CustomAnalyticsFact { Compaction(Box), GuardianReview(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index acc88acf5e..a93f1f8051 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -31,6 +31,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e52b7e7d3b..d49e94a653 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -38,6 +38,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -54,6 +55,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -124,6 +126,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -174,6 +177,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -302,6 +308,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -310,6 +317,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -454,6 +481,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -478,6 +506,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -495,6 +524,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -671,6 +701,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -704,6 +735,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From d70d6e259da9fcaeefcf4b27518bf391e3eeee57 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:34:52 -0700 Subject: [PATCH 316/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 942 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 68874ff1f3..e57c1ed375 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -294,6 +302,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -303,30 +454,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1329,7 +1457,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1386,7 +1514,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1411,6 +1539,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1465,6 +1813,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["ephemeral"], json!(false)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1478,6 +1827,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 8524ddc1e2..5ba60e3ef4 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -4,6 +4,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -20,6 +21,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -266,6 +268,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ce2745cd67..3f149b493a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -40,6 +43,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -375,6 +379,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -497,6 +520,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 2ed0fba7ff..5590fbfa6d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -4,6 +4,7 @@ use crate::events::GuardianReviewEventParams; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +277,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a93f1f8051..1a1a123152 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,6 +3,9 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; @@ -16,14 +19,17 @@ pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -31,8 +37,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d49e94a653..352ea1c892 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -20,15 +21,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -36,15 +40,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -105,6 +114,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -112,6 +122,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -128,6 +145,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -161,6 +179,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -276,22 +302,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -310,6 +343,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -331,6 +365,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -483,16 +518,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -508,6 +600,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -526,6 +619,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -628,6 +722,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -726,7 +884,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -795,6 +953,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 143512f44c..4159827e25 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -504,6 +507,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6569,12 +6588,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6586,6 +6611,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6686,6 +6712,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6710,6 +6737,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6727,6 +6755,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6747,36 +6780,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6789,12 +6837,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6802,6 +6856,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From 1a8e428123e0a7e768830f580acf6581f46d828c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 20:30:09 -0700 Subject: [PATCH 317/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 28 +++++++++++ codex-rs/analytics/src/events.rs | 20 ++++++++ codex-rs/analytics/src/reducer.rs | 48 ++++++++++++++++++- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e57c1ed375..9320030354 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1442,6 +1454,10 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1499,6 +1515,10 @@ fn turn_event_serializes_expected_shape() { "runtime_arch": "aarch64" }, "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1598,6 +1618,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1626,6 +1650,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3f149b493a..ea60a41b4b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -86,6 +86,13 @@ pub(crate) struct CodexRuntimeMetadata { pub(crate) runtime_arch: String, } +pub(crate) struct ThreadLifecycleMetadata { + pub(crate) thread_source: Option<&'static str>, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, +} + #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, @@ -339,6 +346,10 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -386,6 +397,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -524,6 +539,7 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_metadata: ThreadLifecycleMetadata, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -532,6 +548,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source, + parent_thread_id: thread_metadata.parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 352ea1c892..531354a2d0 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -16,6 +16,7 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ThreadLifecycleMetadata; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; @@ -86,12 +87,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +111,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +663,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,6 +766,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -781,6 +795,12 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + ThreadLifecycleMetadata { + thread_source: thread_metadata.thread_source, + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + }, turn_steer, ), })); @@ -807,6 +827,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +855,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +868,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -867,6 +907,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From 2ab4bc05dfd6aec4402e4252070609f07c6efd5d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:31:23 -0700 Subject: [PATCH 318/383] [codex-analytics] feature plumbing and emittance --- .../analytics/src/analytics_client_tests.rs | 497 +++++++++++++++++- codex-rs/analytics/src/client.rs | 22 + codex-rs/analytics/src/events.rs | 56 +- codex-rs/analytics/src/facts.rs | 55 ++ codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 392 +++++++++++++- .../app-server/src/bespoke_event_handling.rs | 33 ++ .../app-server/src/codex_message_processor.rs | 12 + codex-rs/app-server/src/main.rs | 23 +- codex-rs/app-server/src/message_processor.rs | 9 + codex-rs/app-server/tests/common/config.rs | 28 + codex-rs/app-server/tests/common/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 5 + .../app-server/tests/suite/v2/analytics.rs | 53 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 20 +- .../tests/suite/v2/turn_interrupt.rs | 21 +- .../app-server/tests/suite/v2/turn_start.rs | 147 ++++++ .../app-server/tests/suite/v2/turn_steer.rs | 6 +- codex-rs/core/src/codex.rs | 55 ++ codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_tests_guardian.rs | 2 +- codex-rs/core/src/state/session.rs | 12 + .../core/src/thread_rollout_truncation.rs | 12 + codex-rs/protocol/src/protocol.rs | 8 + 26 files changed, 1416 insertions(+), 66 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..0720b47b88 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,7 +7,7 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; -use crate::events::ThreadInitializationMode; +use crate::events::CodexTurnEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,29 +34,46 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError as AppServerTurnError; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -163,6 +180,181 @@ fn sample_thread_resume_response_with_source( } } +fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(request_id), + params: TurnStartParams { + thread_id: thread_id.to_string(), + input: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: vec![], + }, + UserInput::Image { + url: "https://example.com/a.png".to_string(), + }, + ], + ..Default::default() + }, + } +} + +fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnStart { + request_id: RequestId::Integer(request_id), + response: codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + } +} + +fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(455), + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_completed_notification( + thread_id: &str, + turn_id: &str, + status: AppServerTurnStatus, + codex_error_info: Option, +) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status, + error: codex_error_info.map(|codex_error_info| AppServerTurnError { + message: "turn failed".to_string(), + codex_error_info: Some(codex_error_info), + additional_details: None, + }), + started_at: None, + completed_at: Some(456), + duration_ms: Some(1234), + }, + }) +} + +fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { + TurnResolvedConfigFact { + turn_id: turn_id.to_string(), + thread_id: "thread-2".to_string(), + num_input_images: 1, + submission_type: None, + ephemeral: false, + session_source: SessionSource::Exec, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_network_access: true, + collaboration_mode: ModeKind::Plan, + personality: None, + is_first_turn: true, + } +} + +async fn ingest_turn_prerequisites( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + include_initialize: bool, + include_resolved_config: bool, + include_started: bool, +) { + if include_initialize { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + out, + ) + .await; + + if include_resolved_config { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + out, + ) + .await; + } + + if include_started { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-2", "turn-2", + ))), + out, + ) + .await; + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -563,10 +755,6 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize payload[0]["event_params"]["runtime"]["runtime_arch"], "x86_64" ); - assert_eq!(payload[0]["event_params"]["initialization_mode"], "resumed"); - assert_eq!(payload[0]["event_params"]["thread_source"], "user"); - assert_eq!(payload[0]["event_params"]["subagent_source"], json!(null)); - assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } #[tokio::test] @@ -1087,6 +1275,305 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[test] +fn turn_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: crate::events::CodexTurnEventParams { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + submission_type: None, + ephemeral: false, + model: Some("gpt-5".to_string()), + model_provider: "openai".to_string(), + sandbox_policy: Some("read_only"), + reasoning_effort: Some("high".to_string()), + reasoning_summary: Some("detailed".to_string()), + service_tier: "flex".to_string(), + approval_policy: "on-request".to_string(), + approvals_reviewer: "guardian_subagent".to_string(), + sandbox_network_access: true, + collaboration_mode: Some("plan"), + personality: Some("pragmatic".to_string()), + num_input_images: 2, + is_first_turn: true, + status: Some(TurnStatus::Completed), + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: Some(1234), + started_at: Some(455), + completed_at: Some(456), + }, + })); + + let payload = serde_json::to_value(&event).expect("serialize turn event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_event", + "event_params": { + "thread_id": "thread-2", + "turn_id": "turn-2", + "submission_type": null, + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "ephemeral": false, + "model": "gpt-5", + "model_provider": "openai", + "sandbox_policy": "read_only", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_network_access": true, + "collaboration_mode": "plan", + "personality": "pragmatic", + "num_input_images": 2, + "is_first_turn": true, + "status": "completed", + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "duration_ms": 1234, + "started_at": 455, + "completed_at": 456 + } + }"#, + ) + .expect("parse expected turn event"); + + assert_eq!(payload, expected); +} + +#[tokio::test] +async fn turn_lifecycle_emits_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_type"], json!("codex_turn_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); + assert_eq!( + payload["event_params"]["app_server_client"], + json!({ + "product_client_id": "codex-tui", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": null, + }) + ); + assert_eq!( + payload["event_params"]["runtime"], + json!({ + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64", + }) + ); + assert!(payload["event_params"].get("product_client_id").is_none()); + assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["started_at"], json!(455)); + assert_eq!(payload["event_params"]["completed_at"], json!(456)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + +#[tokio::test] +async fn turn_does_not_emit_without_required_prerequisites() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ false, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_lifecycle_emits_failed_turn_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Failed, + Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("failed")); + assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); +} + +#[tokio::test] +async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Interrupted, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["status"], json!("interrupted")); + assert_eq!(payload["event_params"]["turn_error"], json!(null)); +} + +#[tokio::test] +async fn turn_completed_without_started_notification_emits_null_started_at() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!(payload["event_params"]["started_at"], json!(null)); + assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 0d96ee6061..bd4cfdd473 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -14,9 +14,13 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnResolvedConfigFact; use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -167,6 +171,14 @@ impl AnalyticsEventsClient { ))); } + pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + self.record_fact(AnalyticsFact::Request { + connection_id, + request_id, + request: Box::new(request), + }); + } + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { if !self.queue.should_enqueue_app_used(&tracking, &app) { return; @@ -191,6 +203,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), + )); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { @@ -240,6 +258,10 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + 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 618dd8ffeb..4e073c123e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,7 +3,11 @@ use crate::facts::CodexCompactionEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStatus; +use crate::facts::TurnSubmissionType; +use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -21,14 +25,6 @@ pub enum AppServerRpcTransport { InProcess, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ThreadInitializationMode { - New, - Forked, - Resumed, -} - #[derive(Serialize)] pub(crate) struct TrackEventsRequest { pub(crate) events: Vec, @@ -43,6 +39,7 @@ pub(crate) enum TrackEventRequest { AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -330,6 +327,49 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) event_params: CodexCompactionEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) submission_type: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) ephemeral: bool, + pub(crate) model: Option, + pub(crate) model_provider: String, + pub(crate) sandbox_policy: Option<&'static str>, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: String, + pub(crate) approval_policy: String, + pub(crate) approvals_reviewer: String, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: Option<&'static str>, + pub(crate) personality: Option, + pub(crate) num_input_images: usize, + pub(crate) is_first_turn: bool, + pub(crate) status: Option, + pub(crate) turn_error: Option, + pub(crate) steer_count: Option, + pub(crate) total_tool_call_count: Option, + pub(crate) shell_command_count: Option, + pub(crate) file_change_count: Option, + pub(crate) mcp_tool_call_count: Option, + pub(crate) dynamic_tool_call_count: Option, + pub(crate) subagent_tool_call_count: Option, + pub(crate) web_search_count: Option, + pub(crate) image_generation_count: Option, + pub(crate) duration_ms: Option, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 931ae01013..a36ba125a5 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -7,6 +7,15 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -31,6 +40,51 @@ pub fn build_track_events_context( } } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone)] +pub struct TurnResolvedConfigFact { + pub turn_id: String, + pub thread_id: String, + pub num_input_images: usize, + pub submission_type: Option, + pub ephemeral: bool, + pub session_source: SessionSource, + pub model: String, + pub model_provider: String, + pub sandbox_policy: SandboxPolicy, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_network_access: bool, + pub collaboration_mode: ModeKind, + pub personality: Option, + pub is_first_turn: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -156,6 +210,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), GuardianReview(Box), + TurnResolvedConfig(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 9b4cc1e9bc..acc88acf5e 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -27,7 +27,10 @@ pub use facts::CompactionTrigger; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; +pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStatus; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 0ed2899762..e52b7e7d3b 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,12 +6,13 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexTurnEventParams; +use crate::events::CodexTurnEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; -use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -34,11 +35,23 @@ use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; +use crate::facts::ThreadInitializationMode; +use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStatus; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use sha1::Digest; @@ -47,6 +60,8 @@ use std::path::Path; #[derive(Default)] pub(crate) struct AnalyticsReducer { + requests: HashMap<(u64, RequestId), RequestState>, + turns: HashMap, connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, @@ -86,6 +101,32 @@ impl ThreadMetadataState { } } +enum RequestState { + TurnStart(PendingTurnStartState), +} + +struct PendingTurnStartState { + thread_id: String, + num_input_images: usize, +} + +#[derive(Clone)] +struct CompletedTurnState { + status: Option, + turn_error: Option, + completed_at: u64, + duration_ms: Option, +} + +struct TurnState { + connection_id: Option, + thread_id: Option, + num_input_images: Option, + resolved_config: Option, + started_at: Option, + completed: Option, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -105,17 +146,21 @@ impl AnalyticsReducer { ); } AnalyticsFact::Request { - connection_id: _connection_id, - request_id: _request_id, - request: _request, - } => {} + connection_id, + request_id, + request, + } => { + self.ingest_request(connection_id, request_id, *request); + } AnalyticsFact::Response { connection_id, response, } => { self.ingest_response(connection_id, *response, out); } - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification(notification) => { + self.ingest_notification(*notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -126,6 +171,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::GuardianReview(input) => { self.ingest_guardian_review(*input, out); } + CustomAnalyticsFact::TurnResolvedConfig(input) => { + self.ingest_turn_resolved_config(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -216,6 +264,52 @@ impl AnalyticsReducer { ))); } + fn ingest_request( + &mut self, + connection_id: u64, + request_id: RequestId, + request: ClientRequest, + ) { + let ClientRequest::TurnStart { params, .. } = request else { + return; + }; + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: params + .input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + }), + ); + } + + fn ingest_turn_resolved_config( + &mut self, + input: TurnResolvedConfigFact, + out: &mut Vec, + ) { + let turn_id = input.turn_id.clone(); + 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, + started_at: None, + completed: None, + }); + turn_state.thread_id = Some(thread_id); + turn_state.num_input_images = Some(num_input_images); + turn_state.resolved_config = Some(input); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -316,24 +410,124 @@ impl AnalyticsReducer { response: ClientResponse, out: &mut Vec, ) { - let (thread, model, initialization_mode) = match response { - ClientResponse::ThreadStart { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::New, - ), - ClientResponse::ThreadResume { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Resumed, - ), - ClientResponse::ThreadFork { response, .. } => ( - response.thread, - response.model, - ThreadInitializationMode::Forked, - ), - _ => return, - }; + match response { + ClientResponse::ThreadStart { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::New, + out, + ); + } + ClientResponse::ThreadResume { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Resumed, + out, + ); + } + ClientResponse::ThreadFork { response, .. } => { + self.emit_thread_initialized( + connection_id, + response.thread, + response.model, + ThreadInitializationMode::Forked, + out, + ); + } + ClientResponse::TurnStart { + request_id, + response, + } => { + let turn_id = response.turn.id; + let Some(RequestState::TurnStart(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + 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, + started_at: None, + completed: None, + }); + 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); + } + _ => {} + } + } + + fn ingest_notification( + &mut self, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + 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, + started_at: None, + completed: None, + }); + turn_state.started_at = notification + .turn + .started_at + .and_then(|started_at| u64::try_from(started_at).ok()); + } + ServerNotification::TurnCompleted(notification) => { + let turn_state = + self.turns + .entry(notification.turn.id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + completed: None, + }); + turn_state.completed = Some(CompletedTurnState { + status: analytics_turn_status(notification.turn.status), + turn_error: notification + .turn + .error + .and_then(|error| error.codex_error_info), + completed_at: notification + .turn + .completed_at + .and_then(|completed_at| u64::try_from(completed_at).ok()) + .unwrap_or_default(), + duration_ms: notification + .turn + .duration_ms + .and_then(|duration_ms| u64::try_from(duration_ms).ok()), + }); + let turn_id = notification.turn.id; + self.maybe_emit_turn_event(&turn_id, out); + } + _ => {} + } + } + + fn emit_thread_initialized( + &mut self, + connection_id: u64, + thread: codex_app_server_protocol::Thread, + model: String, + initialization_mode: ThreadInitializationMode, + out: &mut Vec, + ) { let thread_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { @@ -403,6 +597,156 @@ impl AnalyticsReducer { }, ))); } + + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + let Some(turn_state) = self.turns.get(turn_id) else { + return; + }; + if turn_state.thread_id.is_none() + || turn_state.num_input_images.is_none() + || turn_state.resolved_config.is_none() + || turn_state.completed.is_none() + { + 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 { + return; + }; + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + app_server_client, + runtime, + turn_id.to_string(), + turn_state, + ), + }, + ))); + self.turns.remove(turn_id); + } +} + +fn codex_turn_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + turn_id: String, + turn_state: &TurnState, +) -> CodexTurnEventParams { + let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + turn_state.thread_id.clone(), + turn_state.num_input_images, + turn_state.resolved_config.clone(), + turn_state.completed.clone(), + ) else { + unreachable!("turn event params require a fully populated turn state"); + }; + let started_at = turn_state.started_at; + let TurnResolvedConfigFact { + turn_id: _resolved_turn_id, + thread_id: _resolved_thread_id, + num_input_images: _resolved_num_input_images, + submission_type, + ephemeral, + session_source: _session_source, + model, + model_provider, + sandbox_policy, + reasoning_effort, + reasoning_summary, + service_tier, + approval_policy, + approvals_reviewer, + sandbox_network_access, + collaboration_mode, + personality, + is_first_turn, + } = resolved_config; + CodexTurnEventParams { + thread_id, + turn_id, + app_server_client, + runtime, + submission_type, + ephemeral, + model: Some(model), + model_provider, + sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + reasoning_effort: reasoning_effort.map(|value| value.to_string()), + reasoning_summary: reasoning_summary_mode(reasoning_summary), + service_tier: service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: approval_policy.to_string(), + approvals_reviewer: approvals_reviewer.to_string(), + sandbox_network_access, + collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), + personality: personality_mode(personality), + num_input_images, + is_first_turn, + status: completed.status, + turn_error: completed.turn_error, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + duration_ms: completed.duration_ms, + started_at, + completed_at: Some(completed.completed_at), + } +} + +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } +} + +fn reasoning_summary_mode(summary: Option) -> Option { + match summary { + Some(ReasoningSummary::None) | None => None, + Some(summary) => Some(summary.to_string()), + } +} + +fn personality_mode(personality: Option) -> Option { + match personality { + Some(Personality::None) | None => None, + Some(personality) => Some(personality.to_string()), + } +} + +fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Option { + match status { + codex_app_server_protocol::TurnStatus::Completed => Some(TurnStatus::Completed), + codex_app_server_protocol::TurnStatus::Failed => Some(TurnStatus::Failed), + codex_app_server_protocol::TurnStatus::Interrupted => Some(TurnStatus::Interrupted), + codex_app_server_protocol::TurnStatus::InProgress => None, + } } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d8990483b7..53361bc3c3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -12,6 +12,7 @@ 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; @@ -167,6 +168,7 @@ 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, @@ -202,6 +204,10 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn, }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); + } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -218,6 +224,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1738,6 +1745,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, + analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1915,6 +1923,7 @@ 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 { @@ -1929,6 +1938,10 @@ 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; @@ -2121,6 +2134,7 @@ 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>, ) { @@ -2141,6 +2155,7 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2150,6 +2165,7 @@ 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>, ) { @@ -2165,6 +2181,7 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, + analytics_events_client, outgoing, ) .await; @@ -2905,6 +2922,7 @@ 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; @@ -3032,6 +3050,7 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, + analytics_events_client: AnalyticsEventsClient, codex_home: PathBuf, } @@ -3046,6 +3065,7 @@ 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(), @@ -3372,6 +3392,13 @@ 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(), }; @@ -3822,6 +3849,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3870,6 +3898,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3917,6 +3946,7 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4183,6 +4213,7 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4204,6 +4235,7 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -4215,6 +4247,7 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), + /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e0509907b9..ebc6a1aab9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6669,6 +6669,15 @@ impl CodexMessageProcessor { }; let response = TurnStartResponse { turn }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -7480,6 +7489,9 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), + listener_task_context + .general_analytics_enabled + .then(|| listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 9a23680fb9..d896f2f8ec 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; // Debug-only test hook: lets integration tests point the server at a temporary // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] struct AppServerArgs { @@ -40,10 +41,13 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let args = AppServerArgs::parse(); - let managed_config_path = managed_config_path_from_debug_env(); - let loader_overrides = LoaderOverrides { - managed_config_path, - ..Default::default() + let loader_overrides = if disable_managed_config_from_debug_env() { + LoaderOverrides::without_managed_config_for_tests() + } else { + LoaderOverrides { + managed_config_path: managed_config_path_from_debug_env(), + ..Default::default() + } }; let transport = args.listen; let session_source = args.session_source; @@ -63,6 +67,17 @@ fn main() -> anyhow::Result<()> { }) } +fn disable_managed_config_from_debug_env() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(DISABLE_MANAGED_CONFIG_ENV_VAR) { + return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); + } + } + + false +} + fn managed_config_path_from_debug_env() -> Option { #[cfg(debug_assertions)] { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8c2bba00a3..4c47ec15aa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -677,6 +677,15 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } + if self.config.features.enabled(Feature::GeneralAnalytics) + && let ClientRequest::TurnStart { request_id, .. } = &codex_request + { + self.analytics_events_client.track_request( + connection_id.0, + request_id.clone(), + codex_request.clone(), + ); + } match codex_request { ClientRequest::ConfigRead { request_id, params } => { diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index deb16c6322..1ac2572fa2 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -78,3 +78,31 @@ model_provider = "{model_provider_id}" ), ) } + +pub fn write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 3f89765851..90553760d9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -14,6 +14,7 @@ pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use config::write_mock_responses_config_toml; +pub use config::write_mock_responses_config_toml_with_chatgpt_base_url; pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display; pub use core_test_support::format_with_current_shell_display_non_login; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b84..69d209c020 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -98,12 +98,17 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[]).await } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { + Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], args).await } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a4d7a7f349..a3ecdbc1f4 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -80,6 +80,24 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { } pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + if !config_toml.contains("[features]") { + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), + )?; + } else if !config_toml.contains("general_analytics") { + std::fs::write( + &config_path, + config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), + )?; + } + + mount_analytics_capture(server, codex_home).await +} + +pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) @@ -120,6 +138,41 @@ pub(crate) async fn wait_for_analytics_payload( serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) } +pub(crate) async fn wait_for_analytics_event( + server: &MockServer, + read_timeout: Duration, + event_type: &str, +) -> Result { + timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + if let Some(event) = events + .iter() + .find(|event| event["event_type"] == event_type) + { + return Ok::(event.clone()); + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await? +} + pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { let events = payload["events"] .as_array() diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..19bf00f64a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -205,7 +205,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -565,7 +565,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( 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 80ae756888..0a302c2380 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -178,7 +178,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1901,7 +1901,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 50c373ac93..4fcba24272 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -39,8 +39,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -173,9 +174,9 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { &server.uri(), /*general_analytics_enabled*/ true, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -206,9 +207,9 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur &server.uri(), /*general_analytics_enabled*/ false, )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -221,7 +222,12 @@ async fn thread_start_does_not_track_thread_initialized_analytics_without_featur .await??; let _ = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + let payload = wait_for_analytics_event( + &server, + Duration::from_millis(250), + "codex_thread_initialized", + ) + .await; assert!( payload.is_err(), "thread analytics should be gated off when general_analytics is disabled" @@ -829,7 +835,7 @@ fn create_config_toml_with_chatgpt_base_url( let general_analytics_toml = if general_analytics_enabled { "\ngeneral_analytics = true".to_string() } else { - String::new() + "\ngeneral_analytics = false".to_string() }; let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 2850c7b74f..b553137752 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; @@ -43,14 +44,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -87,6 +89,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { ) .await??; let TurnStartResponse { turn } = to_response::(turn_resp)?; + let turn_id = turn.id.clone(); // Give the command a brief moment to start. tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -96,7 +99,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let interrupt_id = mcp .send_turn_interrupt_request(TurnInterruptParams { thread_id: thread_id.clone(), - turn_id: turn.id, + turn_id: turn_id.clone(), }) .await?; let interrupt_resp: JSONRPCResponse = 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 d81a5d27a0..ef1b2c0400 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; @@ -9,6 +10,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; @@ -64,6 +66,10 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_analytics_event; + #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] @@ -328,6 +334,147 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Ok(()) } +#[tokio::test] +async fn turn_start_tracks_turn_event_analytics() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Image { + url: "https://example.com/a.png".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["turn_id"], turn.id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!(event["event_params"]["model"], "mock-model"); + assert_eq!(event["event_params"]["model_provider"], "mock_provider"); + assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["num_input_images"], 1); + assert_eq!(event["event_params"]["status"], "completed"); + assert!(event["event_params"]["started_at"].as_u64().is_some()); + assert!(event["event_params"]["completed_at"].as_u64().is_some()); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _ = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let turn_event = wait_for_analytics_event( + &server, + std::time::Duration::from_millis(250), + "codex_turn_event", + ) + .await; + assert!( + turn_event.is_err(), + "turn analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a93bf6c6ab..e66dfc9e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -34,7 +34,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let server = create_mock_responses_server_sequence(vec![]).await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -98,7 +98,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -202,7 +202,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cd6f1e84fd..49588490ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,7 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::InvocationType; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::McpServerElicitationRequest; @@ -188,6 +189,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; +use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; use codex_config::types::ShellEnvironmentPolicy; @@ -2339,6 +2341,11 @@ impl Session { SessionSource::SubAgent(_) ) }; + let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history); + { + let mut state = self.state.lock().await; + state.set_next_turn_is_first(!has_prior_user_turns); + } match conversation_history { InitialHistory::New | InitialHistory::Cleared => { // Defer initial context insertion until the first real turn starts so @@ -6187,6 +6194,8 @@ pub(crate) async fn run_turn( .await; } + track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; @@ -6490,6 +6499,52 @@ pub(crate) async fn run_turn( last_agent_message } +async fn track_turn_resolved_config_analytics( + sess: &Session, + turn_context: &TurnContext, + input: &[UserInput], +) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + + let thread_config = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + let is_first_turn = { + let mut state = sess.state.lock().await; + state.take_next_turn_is_first() + }; + sess.services + .analytics_events_client + .track_turn_resolved_config(TurnResolvedConfigFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + num_input_images: input + .iter() + .filter(|item| { + matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) + }) + .count(), + submission_type: None, + ephemeral: thread_config.ephemeral, + session_source: thread_config.session_source, + model: turn_context.model_info.slug.clone(), + model_provider: turn_context.config.model_provider_id.clone(), + sandbox_policy: turn_context.sandbox_policy.get().clone(), + reasoning_effort: turn_context.reasoning_effort, + reasoning_summary: Some(turn_context.reasoning_summary), + service_tier: turn_context.config.service_tier, + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(), + collaboration_mode: turn_context.collaboration_mode.mode, + personality: turn_context.personality, + is_first_turn, + }); +} + async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4deb7d3cfd..8257369048 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -78,7 +78,6 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), models_manager, environment_manager: Arc::new(EnvironmentManager::from_environment( parent_ctx.environment.as_deref(), @@ -97,6 +96,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), }) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 70a70a367c..4762d5d8c6 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -436,7 +436,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, auth_manager, - analytics_events_client: None, models_manager, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, @@ -455,6 +454,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + analytics_events_client: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060c..4360b16de4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -33,6 +33,7 @@ pub(crate) struct SessionState { pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, granted_permissions: Option, + next_turn_is_first: bool, } impl SessionState { @@ -51,6 +52,7 @@ impl SessionState { active_connector_selection: HashSet::new(), pending_session_start_source: None, granted_permissions: None, + next_turn_is_first: true, } } @@ -73,6 +75,16 @@ impl SessionState { self.previous_turn_settings = previous_turn_settings; } + pub(crate) fn set_next_turn_is_first(&mut self, value: bool) { + self.next_turn_is_first = value; + } + + pub(crate) fn take_next_turn_is_first(&mut self) -> bool { + let is_first_turn = self.next_turn_is_first; + self.next_turn_is_first = false; + is_first_turn + } + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 97370ce41e..e20ee53d47 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -8,9 +8,21 @@ use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; +pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool { + conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary) +} + +fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(item) => is_user_turn_boundary(item), + _ => false, + } +} + /// Return the indices of user message boundaries in a rollout. /// /// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })` diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2c2c9cabec..fc20a527cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2430,6 +2430,14 @@ pub enum InitialHistory { } impl InitialHistory { + pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool { + match self { + InitialHistory::New | InitialHistory::Cleared => false, + InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate), + InitialHistory::Forked(items) => items.iter().any(predicate), + } + } + pub fn forked_from_id(&self) -> Option { match self { InitialHistory::New | InitialHistory::Cleared => None, From 2d612f947636b908aba38b07c0e7bfa39e0dc291 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 20:30:09 -0700 Subject: [PATCH 319/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 28 +++++++++++ codex-rs/analytics/src/events.rs | 20 ++++++++ codex-rs/analytics/src/reducer.rs | 48 ++++++++++++++++++- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e57c1ed375..9320030354 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1442,6 +1454,10 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1499,6 +1515,10 @@ fn turn_event_serializes_expected_shape() { "runtime_arch": "aarch64" }, "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1598,6 +1618,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1626,6 +1650,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3f149b493a..ea60a41b4b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -86,6 +86,13 @@ pub(crate) struct CodexRuntimeMetadata { pub(crate) runtime_arch: String, } +pub(crate) struct ThreadLifecycleMetadata { + pub(crate) thread_source: Option<&'static str>, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, +} + #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, @@ -339,6 +346,10 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -386,6 +397,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -524,6 +539,7 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_metadata: ThreadLifecycleMetadata, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -532,6 +548,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source, + parent_thread_id: thread_metadata.parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 352ea1c892..531354a2d0 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -16,6 +16,7 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ThreadLifecycleMetadata; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; @@ -86,12 +87,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +111,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +663,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,6 +766,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -781,6 +795,12 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + ThreadLifecycleMetadata { + thread_source: thread_metadata.thread_source, + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + }, turn_steer, ), })); @@ -807,6 +827,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +855,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +868,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -867,6 +907,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From 48a801a22cb85fd7c6e97ef8e52e2ed8fbdb92af Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:34:52 -0700 Subject: [PATCH 320/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 42 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 942 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 68874ff1f3..e57c1ed375 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -294,6 +302,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -303,30 +454,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1329,7 +1457,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1386,7 +1514,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1411,6 +1539,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1465,6 +1813,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["ephemeral"], json!(false)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1478,6 +1827,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 8524ddc1e2..5ba60e3ef4 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -4,6 +4,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -20,6 +21,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -266,6 +268,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ce2745cd67..3f149b493a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -40,6 +43,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -375,6 +379,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -497,6 +520,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 2ed0fba7ff..5590fbfa6d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -4,6 +4,7 @@ use crate::events::GuardianReviewEventParams; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +277,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a93f1f8051..1a1a123152 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,6 +3,9 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; @@ -16,14 +19,17 @@ pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -31,8 +37,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d49e94a653..352ea1c892 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -20,15 +21,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -36,15 +40,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -105,6 +114,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -112,6 +122,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -128,6 +145,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -161,6 +179,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -276,22 +302,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -310,6 +343,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -331,6 +365,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -483,16 +518,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -508,6 +600,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -526,6 +619,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -628,6 +722,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -726,7 +884,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -795,6 +953,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ebc6a1aab9..e7ba062dad 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -504,6 +507,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6569,12 +6588,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6586,6 +6611,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6686,6 +6712,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6710,6 +6737,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6727,6 +6755,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6747,36 +6780,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6789,12 +6837,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6802,6 +6856,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From de7d0810ddfa1833d171453fdc2d99d641a5fede Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:34:52 -0700 Subject: [PATCH 321/383] [codex-analytics] add token usage metadata --- .../analytics/src/analytics_client_tests.rs | 60 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 +++ codex-rs/analytics/src/events.rs | 5 ++ codex-rs/analytics/src/facts.rs | 9 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 46 ++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 5 ++ codex-rs/core/src/tasks/mod.rs | 8 +++ 8 files changed, 141 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0720b47b88..68874ff1f3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -38,6 +38,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; use crate::reducer::skill_id_for_local_skill; @@ -75,6 +76,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -231,6 +233,20 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot }) } +fn sample_turn_token_usage_fact(thread_id: &str, turn_id: &str) -> TurnTokenUsageFact { + TurnTokenUsageFact { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + token_usage: TokenUsage { + total_tokens: 321, + input_tokens: 123, + cached_input_tokens: 45, + output_tokens: 140, + reasoning_output_tokens: 13, + }, + } +} + fn sample_turn_completed_notification( thread_id: &str, turn_id: &str, @@ -284,6 +300,7 @@ async fn ingest_turn_prerequisites( include_initialize: bool, include_resolved_config: bool, include_started: bool, + include_token_usage: bool, ) { if include_initialize { reducer @@ -353,6 +370,17 @@ async fn ingest_turn_prerequisites( ) .await; } + + if include_token_usage { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new( + sample_turn_token_usage_fact("thread-2", "turn-2"), + ))), + out, + ) + .await; + } } fn expected_absolute_path(path: &PathBuf) -> String { @@ -1310,6 +1338,11 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -1362,6 +1395,11 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, "duration_ms": 1234, "started_at": 455, "completed_at": 456 @@ -1384,6 +1422,7 @@ async fn turn_lifecycle_emits_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ true, ) .await; reducer @@ -1429,6 +1468,14 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(123)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(45)); + assert_eq!(payload["event_params"]["output_tokens"], json!(140)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(13) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } #[tokio::test] @@ -1442,6 +1489,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ false, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1466,6 +1514,7 @@ async fn turn_does_not_emit_without_required_prerequisites() { /*include_initialize*/ true, /*include_resolved_config*/ false, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1493,6 +1542,7 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1524,6 +1574,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ true, + /*include_token_usage*/ false, ) .await; reducer @@ -1555,6 +1606,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { /*include_initialize*/ true, /*include_resolved_config*/ true, /*include_started*/ false, + /*include_token_usage*/ false, ) .await; reducer @@ -1572,6 +1624,14 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["started_at"], json!(null)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); + assert_eq!(payload["event_params"]["input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["cached_input_tokens"], json!(null)); + assert_eq!(payload["event_params"]["output_tokens"], json!(null)); + assert_eq!( + payload["event_params"]["reasoning_output_tokens"], + json!(null) + ); + assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } fn sample_plugin_metadata() -> PluginTelemetryMetadata { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index bd4cfdd473..8524ddc1e2 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -15,6 +15,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; @@ -209,6 +210,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 4e073c123e..ce2745cd67 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -359,6 +359,11 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) input_tokens: Option, + pub(crate) cached_input_tokens: Option, + pub(crate) output_tokens: Option, + pub(crate) reasoning_output_tokens: Option, + pub(crate) total_tokens: Option, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a36ba125a5..2ed0fba7ff 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -18,6 +18,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; use std::path::PathBuf; @@ -77,6 +78,13 @@ pub enum ThreadInitializationMode { Resumed, } +#[derive(Clone)] +pub struct TurnTokenUsageFact { + pub turn_id: String, + pub thread_id: String, + pub token_usage: TokenUsage, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -211,6 +219,7 @@ pub(crate) enum CustomAnalyticsFact { Compaction(Box), GuardianReview(Box), TurnResolvedConfig(Box), + TurnTokenUsage(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index acc88acf5e..a93f1f8051 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -31,6 +31,7 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index e52b7e7d3b..d49e94a653 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -38,6 +38,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnTokenUsageFact; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; @@ -54,6 +55,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; @@ -124,6 +126,7 @@ struct TurnState { num_input_images: Option, resolved_config: Option, started_at: Option, + token_usage: Option, completed: Option, } @@ -174,6 +177,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnResolvedConfig(input) => { self.ingest_turn_resolved_config(*input, out); } + CustomAnalyticsFact::TurnTokenUsage(input) => { + self.ingest_turn_token_usage(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -302,6 +308,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.thread_id = Some(thread_id); @@ -310,6 +317,26 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_token_usage( + &mut self, + input: TurnTokenUsageFact, + out: &mut Vec, + ) { + 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, + started_at: None, + token_usage: None, + completed: None, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.token_usage = Some(input.token_usage); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -454,6 +481,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.connection_id = Some(connection_id); @@ -478,6 +506,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.started_at = notification @@ -495,6 +524,7 @@ impl AnalyticsReducer { num_input_images: None, resolved_config: None, started_at: None, + token_usage: None, completed: None, }); turn_state.completed = Some(CompletedTurnState { @@ -671,6 +701,7 @@ fn codex_turn_event_params( personality, is_first_turn, } = resolved_config; + let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, turn_id, @@ -704,6 +735,21 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.input_tokens), + cached_input_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.cached_input_tokens), + output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.output_tokens), + reasoning_output_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.reasoning_output_tokens), + total_tokens: token_usage + .as_ref() + .map(|token_usage| token_usage.total_tokens), duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), 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 ef1b2c0400..ea3ba02b46 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -400,6 +400,11 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert!(event["event_params"]["started_at"].as_u64().is_some()); assert!(event["event_params"]["completed_at"].as_u64().is_some()); assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert_eq!(event["event_params"]["input_tokens"], 0); + assert_eq!(event["event_params"]["cached_input_tokens"], 0); + assert_eq!(event["event_params"]["output_tokens"], 0); + assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); + assert_eq!(event["event_params"]["total_tokens"], 0); Ok(()) } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..f17017316e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,6 +30,7 @@ use crate::hook_runtime::record_pending_input; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::ModelsManager; use codex_otel::SessionTelemetry; @@ -497,6 +498,13 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; + self.services + .analytics_events_client + .track_turn_token_usage(TurnTokenUsageFact { + turn_id: turn_context.sub_id.clone(), + thread_id: self.conversation_id.to_string(), + token_usage: turn_token_usage.clone(), + }); self.services.session_telemetry.histogram( TURN_TOKEN_USAGE_METRIC, turn_token_usage.total_tokens, From fd4d5412abac2717e58c01449461a6d7fa75db0a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 21:27:14 -0700 Subject: [PATCH 322/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 28 +++++++++++ codex-rs/analytics/src/events.rs | 20 ++++++++ codex-rs/analytics/src/reducer.rs | 48 ++++++++++++++++++- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e57c1ed375..9320030354 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1442,6 +1454,10 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1499,6 +1515,10 @@ fn turn_event_serializes_expected_shape() { "runtime_arch": "aarch64" }, "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1598,6 +1618,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1626,6 +1650,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3f149b493a..c4ab4225f8 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -86,6 +86,13 @@ pub(crate) struct CodexRuntimeMetadata { pub(crate) runtime_arch: String, } +pub(crate) struct ThreadMetadata { + pub(crate) thread_source: Option<&'static str>, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, +} + #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, @@ -339,6 +346,10 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -386,6 +397,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -524,6 +539,7 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_metadata: ThreadMetadata, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -532,6 +548,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source, + parent_thread_id: thread_metadata.parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 352ea1c892..33843a30bc 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -16,6 +16,7 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ThreadMetadata; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; @@ -86,12 +87,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_lifecycle( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +111,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +663,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_lifecycle(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,6 +766,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -781,6 +795,12 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + ThreadMetadata { + thread_source: thread_metadata.thread_source, + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + }, turn_steer, ), })); @@ -807,6 +827,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +855,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +868,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -867,6 +907,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From 8933400f55f4c2279d27011394e5f476f58918f0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 11 Apr 2026 18:16:04 -0700 Subject: [PATCH 323/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 28 +++++++++++ codex-rs/analytics/src/events.rs | 20 ++++++++ codex-rs/analytics/src/reducer.rs | 48 ++++++++++++++++++- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e57c1ed375..9320030354 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1442,6 +1454,10 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1499,6 +1515,10 @@ fn turn_event_serializes_expected_shape() { "runtime_arch": "aarch64" }, "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1598,6 +1618,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1626,6 +1650,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3f149b493a..c4ab4225f8 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -86,6 +86,13 @@ pub(crate) struct CodexRuntimeMetadata { pub(crate) runtime_arch: String, } +pub(crate) struct ThreadMetadata { + pub(crate) thread_source: Option<&'static str>, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, +} + #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, @@ -339,6 +346,10 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -386,6 +397,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -524,6 +539,7 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_metadata: ThreadMetadata, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -532,6 +548,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source, + parent_thread_id: thread_metadata.parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 352ea1c892..bf61b4ae98 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -16,6 +16,7 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ThreadMetadata; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; @@ -86,12 +87,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_metadata( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +111,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +663,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,6 +766,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -781,6 +795,12 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + ThreadMetadata { + thread_source: thread_metadata.thread_source, + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + }, turn_steer, ), })); @@ -807,6 +827,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +855,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +868,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -867,6 +907,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From bfe4ed7ede23244db8a16c2f0d1cd5f45b1e19fb Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sat, 11 Apr 2026 18:16:04 -0700 Subject: [PATCH 324/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 28 +++++++++++ codex-rs/analytics/src/events.rs | 20 ++++++++ codex-rs/analytics/src/reducer.rs | 48 ++++++++++++++++++- .../app-server/tests/suite/v2/turn_start.rs | 11 +++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e57c1ed375..9320030354 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1442,6 +1454,10 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1499,6 +1515,10 @@ fn turn_event_serializes_expected_shape() { "runtime_arch": "aarch64" }, "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1598,6 +1618,10 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1626,6 +1650,10 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["initialization_mode"], json!("new")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d804f02f30..ef24e6cee3 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -86,6 +86,13 @@ pub(crate) struct CodexRuntimeMetadata { pub(crate) runtime_arch: String, } +pub(crate) struct ThreadMetadata { + pub(crate) thread_source: Option<&'static str>, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, +} + #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, @@ -341,6 +348,10 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -390,6 +401,10 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -528,6 +543,7 @@ pub(crate) fn codex_turn_steer_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, tracking: &TrackEventsContext, + thread_metadata: ThreadMetadata, turn_steer: CodexTurnSteerEvent, ) -> CodexTurnSteerEventParams { CodexTurnSteerEventParams { @@ -536,6 +552,10 @@ pub(crate) fn codex_turn_steer_event_params( accepted_turn_id: turn_steer.accepted_turn_id, app_server_client, runtime, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source, + parent_thread_id: thread_metadata.parent_thread_id, num_input_images: turn_steer.num_input_images, result: turn_steer.result, rejection_reason: turn_steer.rejection_reason, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 352ea1c892..bf61b4ae98 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -16,6 +16,7 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ThreadMetadata; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; @@ -86,12 +87,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_metadata( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +111,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +663,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,6 +766,13 @@ impl AnalyticsReducer { 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" + ); + return; + }; let tracking = TrackEventsContext { model_slug: String::new(), thread_id: pending_request.thread_id, @@ -781,6 +795,12 @@ impl AnalyticsReducer { connection_state.app_server_client.clone(), connection_state.runtime.clone(), &tracking, + ThreadMetadata { + thread_source: thread_metadata.thread_source, + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + }, turn_steer, ), })); @@ -807,6 +827,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +855,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +868,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -867,6 +907,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From f60e831f36fe05ba0336e2422f15aee13349f1e0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Fri, 10 Apr 2026 18:34:52 -0700 Subject: [PATCH 325/383] [codex-analytics] add steering metadata --- .../analytics/src/analytics_client_tests.rs | 504 +++++++++++++++++- codex-rs/analytics/src/client.rs | 17 + codex-rs/analytics/src/events.rs | 46 ++ codex-rs/analytics/src/facts.rs | 75 +++ codex-rs/analytics/src/lib.rs | 16 + codex-rs/analytics/src/reducer.rs | 208 +++++++- .../app-server/src/codex_message_processor.rs | 85 ++- codex-rs/app-server/src/message_processor.rs | 3 +- .../app-server/tests/suite/v2/turn_steer.rs | 77 ++- codex-rs/core/src/compact.rs | 10 +- 10 files changed, 946 insertions(+), 95 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 68874ff1f3..e57c1ed375 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -16,6 +16,7 @@ use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -27,6 +28,7 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -38,6 +40,7 @@ use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use crate::reducer::normalize_path_for_skill_id; @@ -47,8 +50,11 @@ use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -63,6 +69,8 @@ use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; @@ -294,6 +302,149 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { } } +fn sample_turn_steer_request( + thread_id: &str, + expected_turn_id: &str, + request_id: i64, +) -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(request_id), + params: TurnSteerParams { + thread_id: thread_id.to_string(), + expected_turn_id: expected_turn_id.to_string(), + input: vec![ + UserInput::Text { + text: "more".to_string(), + text_elements: vec![], + }, + UserInput::LocalImage { + path: "/tmp/a.png".into(), + }, + ], + responsesapi_client_metadata: None, + }, + } +} + +fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { + ClientResponse::TurnSteer { + request_id: RequestId::Integer(request_id), + response: TurnSteerResponse { + turn_id: turn_id.to_string(), + }, + } +} + +fn no_active_turn_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "no active turn to steer".to_string(), + data: None, + } +} + +fn no_active_turn_steer_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NoActiveTurn) +} + +fn non_steerable_review_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32600, + message: "cannot steer a review turn".to_string(), + data: Some( + serde_json::to_value(AppServerTurnError { + message: "cannot steer a review turn".to_string(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }), + additional_details: None, + }) + .expect("serialize turn error"), + ), + } +} + +fn non_steerable_review_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::TurnSteer(TurnSteerRequestError::NonSteerableReview) +} + +fn input_too_large_steer_error() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32602, + message: "Input exceeds the maximum length of 1048576 characters.".to_string(), + data: Some(json!({ + "input_error_code": "input_too_large", + "actual_chars": 1048577, + "max_chars": 1048576, + })), + } +} + +fn input_too_large_error_type() -> AnalyticsJsonRpcError { + AnalyticsJsonRpcError::Input(InputError::TooLarge) +} + +async fn ingest_rejected_turn_steer( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_turn_prerequisites( + reducer, out, /*include_initialize*/ true, /*include_resolved_config*/ false, + /*include_started*/ false, /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize turn steer event") +} + +async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: None, + }, + product_client_id: "codex-tui".to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + out, + ) + .await; +} + async fn ingest_turn_prerequisites( reducer: &mut AnalyticsReducer, out: &mut Vec, @@ -303,30 +454,7 @@ async fn ingest_turn_prerequisites( include_token_usage: bool, ) { if include_initialize { - reducer - .ingest( - AnalyticsFact::Initialize { - connection_id: 7, - params: InitializeParams { - client_info: ClientInfo { - name: "codex-tui".to_string(), - title: None, - version: "1.0.0".to_string(), - }, - capabilities: None, - }, - product_client_id: "codex-tui".to_string(), - runtime: CodexRuntimeMetadata { - codex_rs_version: "0.1.0".to_string(), - runtime_os: "macos".to_string(), - runtime_os_version: "15.3.1".to_string(), - runtime_arch: "aarch64".to_string(), - }, - rpc_transport: AppServerRpcTransport::Stdio, - }, - out, - ) - .await; + ingest_initialize(reducer, out).await; } reducer @@ -1329,7 +1457,7 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, - steer_count: None, + steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -1386,7 +1514,7 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, - "steer_count": null, + "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, "file_change_count": null, @@ -1411,6 +1539,226 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn accepted_turn_steer_emits_expected_event() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ false, + /*include_started*/ false, + /*include_token_usage*/ false, + ) + .await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!(payload["event_params"]["result"], json!("accepted")); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert!(payload["event_params"].get("product_client_id").is_none()); +} + +#[tokio::test] +async fn rejected_turn_steer_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + no_active_turn_steer_error(), + Some(no_active_turn_steer_error_type()), + ) + .await; + + assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); + assert_eq!(payload["event_params"]["accepted_turn_id"], json!(null)); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["result"], json!("rejected")); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("no_active_turn") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_active_turn_not_steerable_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + non_steerable_review_error(), + Some(non_steerable_review_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("non_steerable_review") + ); +} + +#[tokio::test] +async fn rejected_turn_steer_maps_input_too_large_error_type() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_steer( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); +} + +#[tokio::test] +async fn turn_steer_does_not_emit_without_pending_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(4), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + +#[tokio::test] +async fn turn_start_error_response_discards_pending_start_request() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error: no_active_turn_steer_error(), + error_type: None, + }, + &mut out, + ) + .await; + + // A late/synthetic response for the same request id must not resurrect the + // failed turn/start request and attach request-scoped connection metadata. + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + }, + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( + sample_turn_resolved_config("turn-2"), + ))), + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn turn_lifecycle_emits_turn_event() { let mut reducer = AnalyticsReducer::default(); @@ -1465,6 +1813,7 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["ephemeral"], json!(false)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); + assert_eq!(payload["event_params"]["steer_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -1478,6 +1827,109 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn accepted_steers_increment_turn_steer_count() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(4), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 4, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(5), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 5, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(5), + error: no_active_turn_steer_error(), + error_type: Some(no_active_turn_steer_error_type()), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Request { + connection_id: 7, + request_id: RequestId::Integer(6), + request: Box::new(sample_turn_steer_request( + "thread-2", "turn-2", /*request_id*/ 6, + )), + }, + &mut out, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + }, + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["steer_count"], json!(2)); +} + #[tokio::test] async fn turn_does_not_emit_without_required_prerequisites() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 8524ddc1e2..5ba60e3ef4 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -4,6 +4,7 @@ use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; @@ -20,6 +21,7 @@ use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_login::AuthManager; @@ -266,6 +268,21 @@ impl AnalyticsEventsClient { }); } + pub fn track_error_response( + &self, + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + ) { + self.record_fact(AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error, + error_type, + }); + } + pub fn track_notification(&self, notification: ServerNotification) { self.record_fact(AnalyticsFact::Notification(Box::new(notification))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ce2745cd67..d804f02f30 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,11 +1,14 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; @@ -40,6 +43,7 @@ pub(crate) enum TrackEventRequest { AppUsed(CodexAppUsedEventRequest), Compaction(Box), TurnEvent(Box), + TurnSteer(CodexTurnSteerEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -331,6 +335,8 @@ pub(crate) struct CodexCompactionEventRequest { pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, pub(crate) turn_id: String, + // TODO(rhan-oai): Populate once queued/default submission type is plumbed from + // the turn/start callsites instead of always being reported as None. pub(crate) submission_type: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -351,6 +357,8 @@ pub(crate) struct CodexTurnEventParams { pub(crate) status: Option, pub(crate) turn_error: Option, pub(crate) steer_count: Option, + // TODO(rhan-oai): Populate these once tool-call accounting is emitted from + // core; the schema is reserved but these fields are currently always None. pub(crate) total_tool_call_count: Option, pub(crate) shell_command_count: Option, pub(crate) file_change_count: Option, @@ -375,6 +383,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventParams { + pub(crate) thread_id: String, + pub(crate) expected_turn_id: Option, + pub(crate) accepted_turn_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) num_input_images: usize, + pub(crate) result: TurnSteerResult, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnSteerEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnSteerEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexPluginMetadata { pub(crate) plugin_id: Option, @@ -497,6 +524,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_turn_steer_event_params( + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, + tracking: &TrackEventsContext, + turn_steer: CodexTurnSteerEvent, +) -> CodexTurnSteerEventParams { + CodexTurnSteerEventParams { + thread_id: tracking.thread_id.clone(), + expected_turn_id: turn_steer.expected_turn_id, + accepted_turn_id: turn_steer.accepted_turn_id, + app_server_client, + runtime, + num_input_images: turn_steer.num_input_images, + result: turn_steer.result, + rejection_reason: turn_steer.rejection_reason, + created_at: turn_steer.created_at, + } +} + pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 2ed0fba7ff..5590fbfa6d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -4,6 +4,7 @@ use crate::events::GuardianReviewEventParams; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_plugin::PluginTelemetryMetadata; @@ -93,6 +94,74 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerResult { + Accepted, + Rejected, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSteerRejectionReason { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, + EmptyInput, + InputTooLarge, +} + +#[derive(Clone)] +pub struct CodexTurnSteerEvent { + pub expected_turn_id: Option, + pub accepted_turn_id: Option, + pub num_input_images: usize, + pub result: TurnSteerResult, + pub rejection_reason: Option, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum AnalyticsJsonRpcError { + TurnSteer(TurnSteerRequestError), + Input(InputError), +} + +#[derive(Clone, Copy, Debug)] +pub enum TurnSteerRequestError { + NoActiveTurn, + ExpectedTurnMismatch, + NonSteerableReview, + NonSteerableCompact, +} + +#[derive(Clone, Copy, Debug)] +pub enum InputError { + Empty, + TooLarge, +} + +impl From for TurnSteerRejectionReason { + fn from(error: TurnSteerRequestError) -> Self { + match error { + TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn, + TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch, + TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview, + TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact, + } + } +} + +impl From for TurnSteerRejectionReason { + fn from(error: InputError) -> Self { + match error { + InputError::Empty => Self::EmptyInput, + InputError::TooLarge => Self::InputTooLarge, + } + } +} + #[derive(Clone, Debug)] pub struct SkillInvocation { pub skill_name: String, @@ -208,6 +277,12 @@ pub(crate) enum AnalyticsFact { connection_id: u64, response: Box, }, + ErrorResponse { + connection_id: u64, + request_id: RequestId, + error: JSONRPCErrorError, + error_type: Option, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a93f1f8051..1a1a123152 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,6 +3,9 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; @@ -16,14 +19,17 @@ pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; +pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; @@ -31,8 +37,18 @@ pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; +pub use facts::TurnSteerRejectionReason; +pub use facts::TurnSteerRequestError; +pub use facts::TurnSteerResult; pub use facts::TurnTokenUsageFact; pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d49e94a653..352ea1c892 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -20,15 +21,18 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::events::thread_source_name; use crate::facts::AnalyticsFact; +use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -36,15 +40,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; +use crate::facts::TurnSteerRejectionReason; +use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; +use crate::now_unix_seconds; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; @@ -105,6 +114,7 @@ impl ThreadMetadataState { enum RequestState { TurnStart(PendingTurnStartState), + TurnSteer(PendingTurnSteerState), } struct PendingTurnStartState { @@ -112,6 +122,13 @@ struct PendingTurnStartState { num_input_images: usize, } +struct PendingTurnSteerState { + thread_id: String, + expected_turn_id: String, + num_input_images: usize, + created_at: u64, +} + #[derive(Clone)] struct CompletedTurnState { status: Option, @@ -128,6 +145,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + steer_count: usize, } impl AnalyticsReducer { @@ -161,6 +179,14 @@ impl AnalyticsReducer { } => { self.ingest_response(connection_id, *response, out); } + AnalyticsFact::ErrorResponse { + connection_id, + request_id, + error: _, + error_type, + } => { + self.ingest_error_response(connection_id, request_id, error_type, out); + } AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } @@ -276,22 +302,29 @@ impl AnalyticsReducer { request_id: RequestId, request: ClientRequest, ) { - let ClientRequest::TurnStart { params, .. } = request else { - return; - }; - self.requests.insert( - (connection_id, request_id), - RequestState::TurnStart(PendingTurnStartState { - thread_id: params.thread_id, - num_input_images: params - .input - .iter() - .filter(|item| { - matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) - }) - .count(), - }), - ); + match request { + ClientRequest::TurnStart { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnStart(PendingTurnStartState { + thread_id: params.thread_id, + num_input_images: num_input_images(¶ms.input), + }), + ); + } + ClientRequest::TurnSteer { params, .. } => { + self.requests.insert( + (connection_id, request_id), + RequestState::TurnSteer(PendingTurnSteerState { + thread_id: params.thread_id, + expected_turn_id: params.expected_turn_id, + num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), + }), + ); + } + _ => {} + } } fn ingest_turn_resolved_config( @@ -310,6 +343,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); @@ -331,6 +365,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); @@ -483,16 +518,73 @@ impl AnalyticsReducer { started_at: None, token_usage: None, 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); } + ClientResponse::TurnSteer { + request_id, + response, + } => { + self.ingest_turn_steer_response(connection_id, request_id, response, out); + } _ => {} } } + fn ingest_error_response( + &mut self, + connection_id: u64, + request_id: RequestId, + error_type: Option, + out: &mut Vec, + ) { + let Some(request) = self.requests.remove(&(connection_id, request_id)) else { + return; + }; + self.ingest_request_error_response(connection_id, request, error_type, out); + } + + fn ingest_request_error_response( + &mut self, + connection_id: u64, + request: RequestState, + error_type: Option, + out: &mut Vec, + ) { + match request { + RequestState::TurnStart(_) => {} + RequestState::TurnSteer(pending_request) => { + self.ingest_turn_steer_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } + } + } + + fn ingest_turn_steer_error_response( + &mut self, + 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, + rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_notification( &mut self, notification: ServerNotification, @@ -508,6 +600,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.started_at = notification .turn @@ -526,6 +619,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -628,6 +722,70 @@ impl AnalyticsReducer { ))); } + fn ingest_turn_steer_response( + &mut self, + connection_id: u64, + request_id: RequestId, + response: TurnSteerResponse, + out: &mut Vec, + ) { + let Some(RequestState::TurnSteer(pending_request)) = + self.requests.remove(&(connection_id, request_id)) + else { + return; + }; + if let Some(turn_state) = self.turns.get_mut(&response.turn_id) { + turn_state.steer_count += 1; + } + self.emit_turn_steer_event( + connection_id, + pending_request, + Some(response.turn_id), + TurnSteerResult::Accepted, + /*rejection_reason*/ None, + out, + ); + } + + 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 tracking = TrackEventsContext { + model_slug: String::new(), + thread_id: pending_request.thread_id, + turn_id: accepted_turn_id + .as_deref() + .unwrap_or(pending_request.expected_turn_id.as_str()) + .to_string(), + }; + let turn_steer = CodexTurnSteerEvent { + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }; + out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { + event_type: "codex_turn_steer_event", + event_params: codex_turn_steer_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + &tracking, + turn_steer, + ), + })); + } + fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; @@ -726,7 +884,7 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, - steer_count: None, + steer_count: Some(turn_state.steer_count), total_tool_call_count: None, shell_command_count: None, file_change_count: None, @@ -795,6 +953,22 @@ fn analytics_turn_status(status: codex_app_server_protocol::TurnStatus) -> Optio } } +fn num_input_images(input: &[UserInput]) -> usize { + input + .iter() + .filter(|item| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })) + .count() +} + +fn rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(error) => Some(error.into()), + AnalyticsJsonRpcError::Input(error) => Some(error.into()), + } +} + pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ebc6a1aab9..e7ba062dad 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,9 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -36,7 +39,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; -use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; @@ -504,6 +507,22 @@ impl CodexMessageProcessor { } } + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + } + async fn load_thread( &self, thread_id: &str, @@ -6569,12 +6588,18 @@ impl CodexMessageProcessor { app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6586,6 +6611,7 @@ impl CodexMessageProcessor { ) .await { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6686,6 +6712,7 @@ impl CodexMessageProcessor { message: format!("failed to start turn: {err}"), data: None, }; + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; } } @@ -6710,6 +6737,7 @@ impl CodexMessageProcessor { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { + self.track_error_response(&request_id, &error, /*error_type*/ None); self.outgoing.send_error(request_id, error).await; return; } @@ -6727,6 +6755,11 @@ impl CodexMessageProcessor { .record_request_turn_id(&request_id, ¶ms.expected_turn_id) .await; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); self.outgoing.send_error(request_id, error).await; return; } @@ -6747,36 +6780,51 @@ impl CodexMessageProcessor { { Ok(turn_id) => { let response = TurnSteerResponse { turn_id }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } Err(err) => { - let (code, message, data) = match err { + let (code, message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), ), SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let message = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => { - "cannot steer a review turn".to_string() - } - codex_protocol::protocol::NonSteerableTurnKind::Compact => { - "cannot steer a compact turn".to_string() - } + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), }; let error = TurnError { message: message.clone(), - codex_error_info: Some( - AppServerCodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }, - ), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), additional_details: None, }; let data = match serde_json::to_value(error) { @@ -6789,12 +6837,18 @@ impl CodexMessageProcessor { None } }; - (INVALID_REQUEST_ERROR_CODE, message, data) + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) } SteerInputError::EmptyInput => ( INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; let error = JSONRPCErrorError { @@ -6802,6 +6856,7 @@ impl CodexMessageProcessor { message, data, }; + self.track_error_response(&request_id, &error, error_type); self.outgoing.send_error(request_id, error).await; } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c47ec15aa..18ee4e36b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -678,7 +678,8 @@ impl MessageProcessor { return; } if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } = &codex_request + && let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index e66dfc9e0b..16e28d6cc5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; @@ -23,6 +24,9 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; +use super::analytics::enable_analytics_capture; +use super::analytics::wait_for_analytics_event; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -32,7 +36,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { std::fs::create_dir(&codex_home)?; let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -52,7 +61,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -68,6 +77,21 @@ async fn turn_steer_requires_active_turn() -> Result<()> { .await??; assert_eq!(steer_err.error.code, -32600); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "rejected"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!( + event["event_params"]["expected_turn_id"], + "turn-does-not-exist" + ); + assert_eq!( + event["event_params"]["accepted_turn_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + Ok(()) } @@ -96,7 +120,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -200,7 +229,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri())?; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + enable_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -261,31 +295,20 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + let event = + wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; + assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["result"], "accepted"); + assert_eq!(event["event_params"]["num_input_images"], 0); + assert_eq!(event["event_params"]["expected_turn_id"], turn.id); + assert_eq!(event["event_params"]["accepted_turn_id"], turn.id); + assert_eq!( + event["event_params"]["rejection_reason"], + serde_json::Value::Null + ); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) .await?; Ok(()) } - -fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { From d1315f8e5e021da102f2510d47aa0582f21da088 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 12:27:20 -0700 Subject: [PATCH 326/383] [codex-analytics] guardian review analytics schema polishing --- .../analytics/src/analytics_client_tests.rs | 127 ++++++++++++++++++ codex-rs/analytics/src/events.rs | 64 +++------ codex-rs/analytics/src/lib.rs | 14 +- codex-rs/core/src/compact.rs | 10 +- codex-rs/protocol/src/approvals.rs | 8 ++ codex-rs/protocol/src/protocol.rs | 1 + 6 files changed, 168 insertions(+), 56 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d86159560b..c9ecd0db92 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,12 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::GuardianApprovalRequestSource; +use crate::events::GuardianReviewDecision; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewFailureReason; +use crate::events::GuardianReviewTerminalStatus; +use crate::events::GuardianReviewedAction; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -57,6 +63,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -683,6 +690,126 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + 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: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-guardian", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(Box::new( + GuardianReviewEventParams { + thread_id: "thread-guardian".to_string(), + turn_id: "turn-guardian".to_string(), + review_id: "review-guardian".to_string(), + target_item_id: None, + retry_reason: Some("network retry".to_string()), + approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent, + reviewed_action: GuardianReviewedAction::NetworkAccess { + target: "https://example.com".to_string(), + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + port: 443, + }, + reviewed_action_truncated: false, + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + review_timeout_ms: 90_000, + tool_call_count: None, + time_to_first_token_ms: None, + completion_latency_ms: Some(90_000), + started_at: 100, + completed_at: Some(190), + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: 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_guardian_review"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); + assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); + assert_eq!(payload[0]["event_params"]["target_item_id"], json!(null)); + assert_eq!( + payload[0]["event_params"]["approval_request_source"], + "delegated_subagent" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["type"], + "network_access" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["host"], + "example.com" + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out"); + assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout"); + assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 618dd8ffeb..89a3aaec85 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,11 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionImplementation; +use crate::facts::CompactionPhase; +use crate::facts::CompactionReason; +use crate::facts::CompactionStatus; +use crate::facts::CompactionStrategy; +use crate::facts::CompactionTrigger; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -9,6 +15,10 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianCommandSource; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -147,31 +157,6 @@ pub enum GuardianReviewSessionKind { EphemeralForked, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewRiskLevel { - Low, - Medium, - High, - Critical, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewOutcome { - Allow, - Deny, -} - #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum GuardianApprovalRequestSource { @@ -228,19 +213,12 @@ pub enum GuardianReviewedAction { }, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GuardianCommandSource { - Shell, - UnifiedExec, -} - #[derive(Clone, Serialize)] pub struct GuardianReviewEventParams { pub thread_id: String, pub turn_id: String, pub review_id: String, - pub target_item_id: String, + pub target_item_id: Option, pub retry_reason: Option, pub approval_request_source: GuardianApprovalRequestSource, pub reviewed_action: GuardianReviewedAction, @@ -248,9 +226,9 @@ pub struct GuardianReviewEventParams { pub decision: GuardianReviewDecision, pub terminal_status: GuardianReviewTerminalStatus, pub failure_reason: Option, - pub risk_level: Option, - pub user_authorization: Option, - pub outcome: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, pub rationale: Option, pub guardian_thread_id: Option, pub guardian_session_kind: Option, @@ -258,7 +236,7 @@ pub struct GuardianReviewEventParams { pub guardian_reasoning_effort: Option, pub had_prior_review_context: Option, pub review_timeout_ms: u64, - pub tool_call_count: u64, + pub tool_call_count: Option, pub time_to_first_token_ms: Option, pub completion_latency_ms: Option, pub started_at: u64, @@ -310,12 +288,12 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) thread_source: Option<&'static str>, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, - pub(crate) trigger: crate::facts::CompactionTrigger, - pub(crate) reason: crate::facts::CompactionReason, - pub(crate) implementation: crate::facts::CompactionImplementation, - pub(crate) phase: crate::facts::CompactionPhase, - pub(crate) strategy: crate::facts::CompactionStrategy, - pub(crate) status: crate::facts::CompactionStatus, + pub(crate) trigger: CompactionTrigger, + pub(crate) reason: CompactionReason, + pub(crate) implementation: CompactionImplementation, + pub(crate) phase: CompactionPhase, + pub(crate) strategy: CompactionStrategy, + pub(crate) status: CompactionStatus, pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 9b4cc1e9bc..8c07a26209 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,18 +3,17 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; -pub use events::GuardianCommandSource; pub use events::GuardianReviewDecision; pub use events::GuardianReviewEventParams; pub use events::GuardianReviewFailureReason; -pub use events::GuardianReviewOutcome; -pub use events::GuardianReviewRiskLevel; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; -pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; @@ -32,3 +31,10 @@ pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index b154946f42..366f394433 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -99,6 +99,14 @@ pub enum GuardianUserAuthorization { High, } +/// Final allow/deny outcome returned by the guardian reviewer. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum GuardianAssessmentOutcome { + Allow, + Deny, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum GuardianAssessmentStatus { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 65d6dab04c..1b79829d22 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -67,6 +67,7 @@ pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::GuardianAssessmentAction; pub use crate::approvals::GuardianAssessmentDecisionSource; pub use crate::approvals::GuardianAssessmentEvent; +pub use crate::approvals::GuardianAssessmentOutcome; pub use crate::approvals::GuardianAssessmentStatus; pub use crate::approvals::GuardianCommandSource; pub use crate::approvals::GuardianRiskLevel; From 509a4df311a87c7f7df8b1be4ec92690d4c0b711 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 12:28:32 -0700 Subject: [PATCH 327/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/guardian/review.rs | 576 +++++++++++++++++++++++++-- 2 files changed, 547 insertions(+), 35 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 55b3619e11..d7911ff349 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -40,7 +41,7 @@ use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; +use crate::guardian::review_approval_request_with_source_and_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -747,12 +748,13 @@ fn spawn_guardian_review( let _ = tx.send(ReviewDecision::Denied); return; }; - let decision = runtime.block_on(review_approval_request_with_cancel( + let decision = runtime.block_on(review_approval_request_with_source_and_cancel( &session, &turn, review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index a80a1a83c9..54ddb8a1f1 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,31 +1,43 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentDecisionSource; use codex_protocol::protocol::GuardianAssessmentEvent; +use codex_protocol::protocol::GuardianAssessmentOutcome; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; -use super::GuardianAssessmentOutcome; use super::GuardianRejection; use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -65,9 +77,36 @@ pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &st #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), +} + +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } } fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { @@ -79,6 +118,220 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + .. + } => GuardianReviewedAction::Shell { + command: command.clone(), + command_display: codex_shell_command::parse_command::shlex_join(command), + cwd: cwd.to_string_lossy().into_owned(), + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + justification: justification.clone(), + }, + GuardianApprovalRequest::ExecCommand { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + command: command.clone(), + command_display: codex_shell_command::parse_command::shlex_join(command), + cwd: cwd.to_string_lossy().into_owned(), + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + justification: justification.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + argv, + cwd, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + argv: argv.clone(), + cwd: cwd.to_string_lossy().into_owned(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => { + GuardianReviewedAction::ApplyPatch { + cwd: cwd.to_string_lossy().into_owned(), + files: files + .iter() + .map(|file| file.to_string_lossy().into_owned()) + .collect(), + } + } + GuardianApprovalRequest::NetworkAccess { + target, + host, + protocol, + port, + .. + } => GuardianReviewedAction::NetworkAccess { + target: target.clone(), + host: host.clone(), + protocol: *protocol, + port: *port, + }, + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + rationale: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + }, + None => GuardianReviewMetadataFields::default(), + } +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + retry_reason: self.retry_reason.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome, + rationale: terminal.rationale, + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -105,11 +358,25 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + retry_reason: retry_reason.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -131,6 +398,28 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at: now_unix_seconds(), + }, + ); session .send_event( turn.as_ref(), @@ -156,29 +445,173 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, ) .await; + let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, - GuardianReviewOutcome::TimedOut => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if matches!(assessment.outcome, GuardianAssessmentOutcome::Allow) { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if matches!( + assessment.outcome, + GuardianAssessmentOutcome::Allow + ) { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + rationale: Some(assessment.rationale.clone()), + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + let rationale = "Automatic approval review timed out while evaluating the requested approval." - .to_string(), - }, - GuardianReviewOutcome::Aborted => { + .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -276,11 +709,34 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, ) .await } +pub(crate) async fn review_approval_request_with_source_and_cancel( + session: &Arc, + turn: &Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + cancel_token: CancellationToken, +) -> ReviewDecision { + run_guardian_review( + Arc::clone(session), + Arc::clone(turn), + review_id, + request, + retry_reason, + approval_request_source, + Some(cancel_token), + ) + .await +} + +#[cfg(test)] pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, @@ -295,6 +751,7 @@ pub(crate) async fn review_approval_request_with_cancel( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, Some(cancel_token), ) .await @@ -325,7 +782,12 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); + } }, None => None, }; @@ -375,10 +837,12 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match session + let (session_outcome, session_metadata) = session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), @@ -393,17 +857,63 @@ pub(super) async fn run_guardian_review_session( personality: turn.personality, external_cancel, }) - .await - { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + .await; + + match session_outcome { + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), + } + } + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), + } +} + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); } } From 840c5b078363b6e5ca959bf72039b48a17010797 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 12:28:44 -0700 Subject: [PATCH 328/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review_session.rs | 205 ++++++++++++++++--- 1 file changed, 173 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index f6cd4d02b2..984181ed1f 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -57,10 +59,23 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, + pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -100,6 +115,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -266,10 +296,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -303,16 +337,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -320,9 +355,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -350,20 +388,25 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - run_review_on_session(trunk.as_ref(), ¶ms, deadline).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = + run_review_on_session(trunk.as_ref(), ¶ms, guardian_session_kind, deadline).await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -460,7 +503,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -479,20 +525,26 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await; + let (outcome, _, metadata) = run_review_on_session( + review_session.as_ref(), + ¶ms, + GuardianReviewSessionKind::EphemeralForked, + deadline, + ) + .await; if let Some(review_session) = self.take_active_ephemeral(&review_session).await { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -539,8 +591,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -555,6 +612,16 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -579,6 +646,9 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -598,29 +668,53 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + reviewed_action_truncated, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), - }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, - Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); - } + Err(outcome) => return (outcome, false, guardian_metadata), }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -649,7 +743,8 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { + metadata: &mut GuardianReviewSessionMetadata, +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -658,7 +753,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -668,30 +763,35 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -699,6 +799,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -950,4 +1051,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } From 0beec3cadd0f8cec0532a7864c365e657f92c835 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 12:29:05 -0700 Subject: [PATCH 329/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 +++++++++----- codex-rs/core/src/guardian/mod.rs | 13 +-- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/tests.rs | 87 +++++++++++++++++-- 4 files changed, 143 insertions(+), 48 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index c9cc9d9fa4..bba3bc1c4e 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -168,32 +168,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -386,10 +403,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 5bd5af133f..ac5dde82e8 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -19,6 +19,7 @@ mod review_session; use std::time::Duration; use codex_protocol::protocol::GuardianAssessmentDecisionSource; +use codex_protocol::protocol::GuardianAssessmentOutcome; use serde::Deserialize; use serde::Serialize; @@ -29,7 +30,9 @@ pub(crate) use review::guardian_rejection_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; +pub(crate) use review::review_approval_request_with_source_and_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review_session::GuardianReviewSessionManager; @@ -44,14 +47,6 @@ const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const TRUNCATION_TAG: &str = "truncated"; -/// Final allow/deny outcome returned by the guardian reviewer. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum GuardianAssessmentOutcome { - Allow, - Deny, -} - /// Structured output contract that the guardian reviewer must satisfy. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct GuardianAssessment { @@ -68,7 +63,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 67c30c2bf0..29bbf37d7f 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -570,11 +570,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 0bd5ea27a61304a879ceef3e3191d47fa254dc12 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 13:20:57 -0700 Subject: [PATCH 330/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 16 files changed, 94 insertions(+) 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 d2296c75c5..b93f8b662f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1330,6 +1330,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1404,6 +1405,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1727,6 +1729,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2233,6 +2236,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2270,6 +2274,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2322,6 +2327,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2359,6 +2365,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2533,6 +2540,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2551,6 +2559,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2562,6 +2571,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2596,6 +2606,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2652,6 +2663,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2897,6 +2909,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2956,6 +2969,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3007,6 +3021,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3055,6 +3070,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 08a51108ae..6f1e2abcb9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2969,6 +2969,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d332853167..bc691e929a 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3660f43684..d3d094acfc 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1446,6 +1446,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1631,6 +1632,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1656,6 +1658,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1733,6 +1736,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1751,6 +1755,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1778,6 +1783,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1824,6 +1830,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1847,6 +1854,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1870,6 +1878,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4830,6 +4839,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() turn_id, last_agent_message: None, .. + time_to_first_token_ms: None, }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..72c008aa19 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -527,11 +527,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 733d998536..61ebfadbbf 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -909,6 +909,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1386,6 +1387,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1414,6 +1416,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1b79829d22..832822ec1a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2027,6 +2027,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0bd78f353a..e3e32adf7b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -759,6 +759,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -790,6 +791,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d271fc0076..b23074f791 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -652,6 +652,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -697,6 +698,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -764,6 +766,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -844,6 +847,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1344,6 +1348,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index ecdb15a398..1266a3191b 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -571,6 +571,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -597,6 +598,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -611,6 +613,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -634,6 +637,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 8ac21627cd..f2fb7b9199 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -925,6 +925,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1241,6 +1242,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2181,6 +2183,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From d99dd9be4da0238bde54ef895d6320f44b9f4137 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 13:59:58 -0700 Subject: [PATCH 331/383] [codex-analytics] guardian review analytics schema polishing --- .../analytics/src/analytics_client_tests.rs | 127 ++++++++++++++++++ codex-rs/analytics/src/events.rs | 64 +++------ codex-rs/analytics/src/lib.rs | 14 +- codex-rs/core/src/compact.rs | 10 +- codex-rs/protocol/src/approvals.rs | 8 ++ codex-rs/protocol/src/protocol.rs | 1 + 6 files changed, 168 insertions(+), 56 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e5aca39b8e..907e515287 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,12 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::GuardianApprovalRequestSource; +use crate::events::GuardianReviewDecision; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewFailureReason; +use crate::events::GuardianReviewTerminalStatus; +use crate::events::GuardianReviewedAction; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -57,6 +63,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -685,6 +692,126 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + 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: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-guardian", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(Box::new( + GuardianReviewEventParams { + thread_id: "thread-guardian".to_string(), + turn_id: "turn-guardian".to_string(), + review_id: "review-guardian".to_string(), + target_item_id: None, + retry_reason: Some("network retry".to_string()), + approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent, + reviewed_action: GuardianReviewedAction::NetworkAccess { + target: "https://example.com".to_string(), + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + port: 443, + }, + reviewed_action_truncated: false, + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + review_timeout_ms: 90_000, + tool_call_count: None, + time_to_first_token_ms: None, + completion_latency_ms: Some(90_000), + started_at: 100, + completed_at: Some(190), + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: 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_guardian_review"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); + assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); + assert_eq!(payload[0]["event_params"]["target_item_id"], json!(null)); + assert_eq!( + payload[0]["event_params"]["approval_request_source"], + "delegated_subagent" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["type"], + "network_access" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["host"], + "example.com" + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out"); + assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout"); + assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 618dd8ffeb..89a3aaec85 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,11 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionImplementation; +use crate::facts::CompactionPhase; +use crate::facts::CompactionReason; +use crate::facts::CompactionStatus; +use crate::facts::CompactionStrategy; +use crate::facts::CompactionTrigger; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -9,6 +15,10 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianCommandSource; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -147,31 +157,6 @@ pub enum GuardianReviewSessionKind { EphemeralForked, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewRiskLevel { - Low, - Medium, - High, - Critical, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewOutcome { - Allow, - Deny, -} - #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum GuardianApprovalRequestSource { @@ -228,19 +213,12 @@ pub enum GuardianReviewedAction { }, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GuardianCommandSource { - Shell, - UnifiedExec, -} - #[derive(Clone, Serialize)] pub struct GuardianReviewEventParams { pub thread_id: String, pub turn_id: String, pub review_id: String, - pub target_item_id: String, + pub target_item_id: Option, pub retry_reason: Option, pub approval_request_source: GuardianApprovalRequestSource, pub reviewed_action: GuardianReviewedAction, @@ -248,9 +226,9 @@ pub struct GuardianReviewEventParams { pub decision: GuardianReviewDecision, pub terminal_status: GuardianReviewTerminalStatus, pub failure_reason: Option, - pub risk_level: Option, - pub user_authorization: Option, - pub outcome: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, pub rationale: Option, pub guardian_thread_id: Option, pub guardian_session_kind: Option, @@ -258,7 +236,7 @@ pub struct GuardianReviewEventParams { pub guardian_reasoning_effort: Option, pub had_prior_review_context: Option, pub review_timeout_ms: u64, - pub tool_call_count: u64, + pub tool_call_count: Option, pub time_to_first_token_ms: Option, pub completion_latency_ms: Option, pub started_at: u64, @@ -310,12 +288,12 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) thread_source: Option<&'static str>, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, - pub(crate) trigger: crate::facts::CompactionTrigger, - pub(crate) reason: crate::facts::CompactionReason, - pub(crate) implementation: crate::facts::CompactionImplementation, - pub(crate) phase: crate::facts::CompactionPhase, - pub(crate) strategy: crate::facts::CompactionStrategy, - pub(crate) status: crate::facts::CompactionStatus, + pub(crate) trigger: CompactionTrigger, + pub(crate) reason: CompactionReason, + pub(crate) implementation: CompactionImplementation, + pub(crate) phase: CompactionPhase, + pub(crate) strategy: CompactionStrategy, + pub(crate) status: CompactionStatus, pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 9b4cc1e9bc..8c07a26209 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,18 +3,17 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; -pub use events::GuardianCommandSource; pub use events::GuardianReviewDecision; pub use events::GuardianReviewEventParams; pub use events::GuardianReviewFailureReason; -pub use events::GuardianReviewOutcome; -pub use events::GuardianReviewRiskLevel; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; -pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; @@ -32,3 +31,10 @@ pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index b154946f42..366f394433 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -99,6 +99,14 @@ pub enum GuardianUserAuthorization { High, } +/// Final allow/deny outcome returned by the guardian reviewer. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum GuardianAssessmentOutcome { + Allow, + Deny, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum GuardianAssessmentStatus { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1fc707469e..cb5dea34f0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -67,6 +67,7 @@ pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::GuardianAssessmentAction; pub use crate::approvals::GuardianAssessmentDecisionSource; pub use crate::approvals::GuardianAssessmentEvent; +pub use crate::approvals::GuardianAssessmentOutcome; pub use crate::approvals::GuardianAssessmentStatus; pub use crate::approvals::GuardianCommandSource; pub use crate::approvals::GuardianRiskLevel; From f5641088e556ad56d8a239e85c7386ac5ff32423 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:02:23 -0700 Subject: [PATCH 332/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/guardian/review.rs | 561 +++++++++++++++++++++++++-- 2 files changed, 538 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 55b3619e11..d7911ff349 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -40,7 +41,7 @@ use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; +use crate::guardian::review_approval_request_with_source_and_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -747,12 +748,13 @@ fn spawn_guardian_review( let _ = tx.send(ReviewDecision::Denied); return; }; - let decision = runtime.block_on(review_approval_request_with_cancel( + let decision = runtime.block_on(review_approval_request_with_source_and_cancel( &session, &turn, review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 861d3576cd..2e4a460d1f 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,31 +1,43 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentDecisionSource; use codex_protocol::protocol::GuardianAssessmentEvent; +use codex_protocol::protocol::GuardianAssessmentOutcome; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; -use super::GuardianAssessmentOutcome; use super::GuardianRejection; use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -75,9 +87,36 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), +} + +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } } fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { @@ -89,6 +128,220 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + .. + } => GuardianReviewedAction::Shell { + command: command.clone(), + command_display: codex_shell_command::parse_command::shlex_join(command), + cwd: cwd.to_string_lossy().into_owned(), + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + justification: justification.clone(), + }, + GuardianApprovalRequest::ExecCommand { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + command: command.clone(), + command_display: codex_shell_command::parse_command::shlex_join(command), + cwd: cwd.to_string_lossy().into_owned(), + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + justification: justification.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + argv, + cwd, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + argv: argv.clone(), + cwd: cwd.to_string_lossy().into_owned(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => { + GuardianReviewedAction::ApplyPatch { + cwd: cwd.to_string_lossy().into_owned(), + files: files + .iter() + .map(|file| file.to_string_lossy().into_owned()) + .collect(), + } + } + GuardianApprovalRequest::NetworkAccess { + target, + host, + protocol, + port, + .. + } => GuardianReviewedAction::NetworkAccess { + target: target.clone(), + host: host.clone(), + protocol: *protocol, + port: *port, + }, + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + rationale: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + }, + None => GuardianReviewMetadataFields::default(), + } +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + retry_reason: self.retry_reason.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome, + rationale: terminal.rationale, + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +369,25 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + retry_reason: retry_reason.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +409,28 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at: now_unix_seconds(), + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +456,142 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, ) .await; + let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, - GuardianReviewOutcome::TimedOut => { + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if matches!(assessment.outcome, GuardianAssessmentOutcome::Allow) { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if matches!( + assessment.outcome, + GuardianAssessmentOutcome::Allow + ) { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + rationale: Some(assessment.rationale.clone()), + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -211,7 +618,30 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -309,11 +739,34 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, ) .await } +pub(crate) async fn review_approval_request_with_source_and_cancel( + session: &Arc, + turn: &Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + cancel_token: CancellationToken, +) -> ReviewDecision { + run_guardian_review( + Arc::clone(session), + Arc::clone(turn), + review_id, + request, + retry_reason, + approval_request_source, + Some(cancel_token), + ) + .await +} + +#[cfg(test)] pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, @@ -328,6 +781,7 @@ pub(crate) async fn review_approval_request_with_cancel( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, Some(cancel_token), ) .await @@ -358,7 +812,12 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); + } }, None => None, }; @@ -408,10 +867,12 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match session + let (session_outcome, session_metadata) = session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), @@ -426,17 +887,63 @@ pub(super) async fn run_guardian_review_session( personality: turn.personality, external_cancel, }) - .await - { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + .await; + + match session_outcome { + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), + } + } + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), + } +} + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); } } From e103d1cc2b3857544c4c660be78f662d00093b4e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:02:23 -0700 Subject: [PATCH 333/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review_session.rs | 205 ++++++++++++++++--- 1 file changed, 173 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index f6cd4d02b2..984181ed1f 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -57,10 +59,23 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, + pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -100,6 +115,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -266,10 +296,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -303,16 +337,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -320,9 +355,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -350,20 +388,25 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - run_review_on_session(trunk.as_ref(), ¶ms, deadline).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = + run_review_on_session(trunk.as_ref(), ¶ms, guardian_session_kind, deadline).await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -460,7 +503,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -479,20 +525,26 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await; + let (outcome, _, metadata) = run_review_on_session( + review_session.as_ref(), + ¶ms, + GuardianReviewSessionKind::EphemeralForked, + deadline, + ) + .await; if let Some(review_session) = self.take_active_ephemeral(&review_session).await { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -539,8 +591,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -555,6 +612,16 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -579,6 +646,9 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -598,29 +668,53 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + reviewed_action_truncated, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), - }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, - Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); - } + Err(outcome) => return (outcome, false, guardian_metadata), }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -649,7 +743,8 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { + metadata: &mut GuardianReviewSessionMetadata, +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -658,7 +753,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -668,30 +763,35 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -699,6 +799,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -950,4 +1051,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } From 996334d969126c6b83fc4781816063c86c8ac7b6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:02:23 -0700 Subject: [PATCH 334/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 +++++++++----- codex-rs/core/src/guardian/mod.rs | 13 +-- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/tests.rs | 87 +++++++++++++++++-- 4 files changed, 143 insertions(+), 48 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index c9cc9d9fa4..bba3bc1c4e 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -168,32 +168,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -386,10 +403,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 67e9a828ee..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -19,6 +19,7 @@ mod review_session; use std::time::Duration; use codex_protocol::protocol::GuardianAssessmentDecisionSource; +use codex_protocol::protocol::GuardianAssessmentOutcome; use serde::Deserialize; use serde::Serialize; @@ -30,7 +31,9 @@ pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; +pub(crate) use review::review_approval_request_with_source_and_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review_session::GuardianReviewSessionManager; @@ -45,14 +48,6 @@ const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const TRUNCATION_TAG: &str = "truncated"; -/// Final allow/deny outcome returned by the guardian reviewer. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum GuardianAssessmentOutcome { - Allow, - Deny, -} - /// Structured output contract that the guardian reviewer must satisfy. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct GuardianAssessment { @@ -69,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index b6ba6349f4..26261beba2 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -571,11 +571,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From d772b643ba232bcad1eb602d1eb454305dfed27c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:02:23 -0700 Subject: [PATCH 335/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 16 files changed, 94 insertions(+) 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 d2296c75c5..b93f8b662f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1330,6 +1330,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1404,6 +1405,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1727,6 +1729,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2233,6 +2236,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2270,6 +2274,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2322,6 +2327,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2359,6 +2365,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2533,6 +2540,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2551,6 +2559,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2562,6 +2571,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2596,6 +2606,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2652,6 +2663,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2897,6 +2909,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2956,6 +2969,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3007,6 +3021,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3055,6 +3070,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 8d0d40ff94..9313ccbed9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2996,6 +2996,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5648fffd7c..d592bc5f4b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1446,6 +1446,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1631,6 +1632,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1656,6 +1658,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1733,6 +1736,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1751,6 +1755,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1778,6 +1783,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1824,6 +1830,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1847,6 +1854,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1870,6 +1878,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4830,6 +4839,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() turn_id, last_agent_message: None, .. + time_to_first_token_ms: None, }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..72c008aa19 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -527,11 +527,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cb5dea34f0..536b8b000e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2041,6 +2041,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0bd78f353a..e3e32adf7b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -759,6 +759,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -790,6 +791,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d271fc0076..b23074f791 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -652,6 +652,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -697,6 +698,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -764,6 +766,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -844,6 +847,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1344,6 +1348,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 083cb8069a..47332bfcf2 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 1c43d2e91d..b4726b7cf9 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -943,6 +943,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1259,6 +1260,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2199,6 +2201,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From bb4e510fa7db5c8124deef750a0d75b97f687bd4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:25:22 -0700 Subject: [PATCH 336/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/guardian/mod.rs | 11 +- codex-rs/core/src/guardian/review.rs | 500 ++++++++++++++++++++++++++- 3 files changed, 491 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 55b3619e11..d7911ff349 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -40,7 +41,7 @@ use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; +use crate::guardian::review_approval_request_with_source_and_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -747,12 +748,13 @@ fn spawn_guardian_review( let _ = tx.send(ReviewDecision::Denied); return; }; - let decision = runtime.block_on(review_approval_request_with_cancel( + let decision = runtime.block_on(review_approval_request_with_source_and_cancel( &session, &turn, review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 67e9a828ee..f5926a0db5 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -19,6 +19,7 @@ mod review_session; use std::time::Duration; use codex_protocol::protocol::GuardianAssessmentDecisionSource; +use codex_protocol::protocol::GuardianAssessmentOutcome; use serde::Deserialize; use serde::Serialize; @@ -30,7 +31,9 @@ pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; +pub(crate) use review::review_approval_request_with_source_and_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review_session::GuardianReviewSessionManager; @@ -45,14 +48,6 @@ const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const TRUNCATION_TAG: &str = "truncated"; -/// Final allow/deny outcome returned by the guardian reviewer. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum GuardianAssessmentOutcome { - Allow, - Deny, -} - /// Structured output contract that the guardian reviewer must satisfy. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct GuardianAssessment { diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 861d3576cd..495fba06a1 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,14 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -10,12 +19,14 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -76,10 +87,34 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { Completed(anyhow::Result), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -89,6 +124,202 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + .. + } => GuardianReviewedAction::Shell { + command: command.clone(), + command_display: codex_shell_command::parse_command::shlex_join(command), + cwd: cwd.to_string_lossy().into_owned(), + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + justification: justification.clone(), + }, + GuardianApprovalRequest::ExecCommand { + command, + cwd, + sandbox_permissions, + additional_permissions, + justification, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + command: command.clone(), + command_display: codex_shell_command::parse_command::shlex_join(command), + cwd: cwd.to_string_lossy().into_owned(), + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + justification: justification.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + argv, + cwd, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + argv: argv.clone(), + cwd: cwd.to_string_lossy().into_owned(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => { + GuardianReviewedAction::ApplyPatch { + cwd: cwd.to_string_lossy().into_owned(), + files: files + .iter() + .map(|file| file.to_string_lossy().into_owned()) + .collect(), + } + } + GuardianApprovalRequest::NetworkAccess { + target, + host, + protocol, + port, + .. + } => GuardianReviewedAction::NetworkAccess { + target: target.clone(), + host: host.clone(), + protocol: *protocol, + port: *port, + }, + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + rationale: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + retry_reason: self.retry_reason.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome, + rationale: terminal.rationale, + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +347,25 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + retry_reason: retry_reason.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +387,28 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at: now_unix_seconds(), + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +434,142 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, ) .await; + let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(Ok(assessment)) => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if matches!(assessment.outcome, GuardianAssessmentOutcome::Allow) { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if matches!( + assessment.outcome, + GuardianAssessmentOutcome::Allow + ) { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + rationale: Some(assessment.rationale.clone()), + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err)) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { + let metadata = GuardianReviewMetadataFields::default(); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +597,29 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + rationale: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -309,11 +717,34 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, ) .await } +pub(crate) async fn review_approval_request_with_source_and_cancel( + session: &Arc, + turn: &Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + cancel_token: CancellationToken, +) -> ReviewDecision { + run_guardian_review( + Arc::clone(session), + Arc::clone(turn), + review_id, + request, + retry_reason, + approval_request_source, + Some(cancel_token), + ) + .await +} + +#[cfg(test)] pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, @@ -328,6 +759,7 @@ pub(crate) async fn review_approval_request_with_cancel( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, Some(cancel_token), ) .await @@ -358,7 +790,9 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + } }, None => None, }; @@ -408,7 +842,7 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), }; match session @@ -428,15 +862,49 @@ pub(super) async fn run_guardian_review_session( }) .await { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), + Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + } + } + None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) } GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, } } + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); + } +} From 1caeafafe32ba707ed259841fe6d9cbd9da84216 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:25:26 -0700 Subject: [PATCH 337/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 93 ++++++--- codex-rs/core/src/guardian/review_session.rs | 205 ++++++++++++++++--- 2 files changed, 239 insertions(+), 59 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 495fba06a1..06e042b815 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -254,6 +258,24 @@ struct GuardianReviewMetadataFields { time_to_first_token_ms: Option, } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -442,8 +464,8 @@ async fn run_guardian_review( let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -479,8 +501,8 @@ async fn run_guardian_review( ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -511,8 +533,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -543,8 +565,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -596,8 +618,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -791,7 +813,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -842,10 +867,12 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match session + let (session_outcome, session_metadata) = session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), @@ -860,25 +887,37 @@ pub(super) async fn run_guardian_review_session( personality: turn.personality, external_cancel, }) - .await - { + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index f6cd4d02b2..984181ed1f 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -57,10 +59,23 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, + pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -100,6 +115,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -266,10 +296,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -303,16 +337,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -320,9 +355,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -350,20 +388,25 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - run_review_on_session(trunk.as_ref(), ¶ms, deadline).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = + run_review_on_session(trunk.as_ref(), ¶ms, guardian_session_kind, deadline).await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -460,7 +503,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -479,20 +525,26 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await; + let (outcome, _, metadata) = run_review_on_session( + review_session.as_ref(), + ¶ms, + GuardianReviewSessionKind::EphemeralForked, + deadline, + ) + .await; if let Some(review_session) = self.take_active_ephemeral(&review_session).await { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -539,8 +591,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -555,6 +612,16 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -579,6 +646,9 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -598,29 +668,53 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + reviewed_action_truncated, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), - }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, - Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); - } + Err(outcome) => return (outcome, false, guardian_metadata), }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -649,7 +743,8 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { + metadata: &mut GuardianReviewSessionMetadata, +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -658,7 +753,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -668,30 +763,35 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -699,6 +799,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -950,4 +1051,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } From 449be750aff7aa05beb5a123daf50e32e1dfd48a Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:25:26 -0700 Subject: [PATCH 338/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 +++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/tests.rs | 87 +++++++++++++++++-- 4 files changed, 140 insertions(+), 40 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index c9cc9d9fa4..bba3bc1c4e 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -168,32 +168,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -386,10 +403,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f5926a0db5..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -64,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index b6ba6349f4..26261beba2 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -571,11 +571,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From dd5d51d9e8943889a5e9121ee228cbf78f6c85cd Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:25:26 -0700 Subject: [PATCH 339/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 16 files changed, 94 insertions(+) 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 d2296c75c5..b93f8b662f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1330,6 +1330,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1404,6 +1405,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1727,6 +1729,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2233,6 +2236,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2270,6 +2274,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2322,6 +2327,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2359,6 +2365,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2533,6 +2540,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2551,6 +2559,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2562,6 +2571,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2596,6 +2606,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2652,6 +2663,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2897,6 +2909,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2956,6 +2969,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3007,6 +3021,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3055,6 +3070,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 8d0d40ff94..9313ccbed9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2996,6 +2996,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5648fffd7c..d592bc5f4b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1446,6 +1446,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1631,6 +1632,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1656,6 +1658,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1733,6 +1736,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1751,6 +1755,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1778,6 +1783,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1824,6 +1830,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1847,6 +1854,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1870,6 +1878,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4830,6 +4839,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() turn_id, last_agent_message: None, .. + time_to_first_token_ms: None, }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..72c008aa19 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -527,11 +527,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cb5dea34f0..536b8b000e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2041,6 +2041,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0bd78f353a..e3e32adf7b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -759,6 +759,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -790,6 +791,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d271fc0076..b23074f791 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -652,6 +652,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -697,6 +698,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -764,6 +766,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -844,6 +847,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1344,6 +1348,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 083cb8069a..47332bfcf2 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 1c43d2e91d..b4726b7cf9 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -943,6 +943,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1259,6 +1260,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2199,6 +2201,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From 922c249af8ec4fbd98e3c08e357a75b74c0454bc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 15:40:23 -0700 Subject: [PATCH 340/383] [codex-analytics] denormalize thread metadata onto turn events --- .../analytics/src/analytics_client_tests.rs | 26 ++++++ codex-rs/analytics/src/events.rs | 27 ++----- codex-rs/analytics/src/reducer.rs | 79 +++++++++++++------ .../app-server/tests/suite/v2/turn_start.rs | 11 +++ 4 files changed, 97 insertions(+), 46 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e57c1ed375..0ba8562efe 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -455,6 +455,18 @@ async fn ingest_turn_prerequisites( ) { if include_initialize { ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); } reducer @@ -1442,6 +1454,10 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, + thread_source: Some("user".to_string()), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, model: Some("gpt-5".to_string()), model_provider: "openai".to_string(), sandbox_policy: Some("read_only"), @@ -1499,6 +1515,10 @@ fn turn_event_serializes_expected_shape() { "runtime_arch": "aarch64" }, "ephemeral": false, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, "model": "gpt-5", "model_provider": "openai", "sandbox_policy": "read_only", @@ -1598,6 +1618,9 @@ async fn accepted_turn_steer_emits_expected_event() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert!(payload["event_params"].get("product_client_id").is_none()); } @@ -1626,6 +1649,9 @@ async fn rejected_turn_steer_uses_request_connection_metadata() { payload["event_params"]["runtime"]["codex_rs_version"], json!("0.1.0") ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); assert_eq!(payload["event_params"]["result"], json!("rejected")); assert_eq!( payload["event_params"]["rejection_reason"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d804f02f30..87f5a4fbed 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,6 +1,5 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; -use crate::facts::CodexTurnSteerEvent; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -341,6 +340,10 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, + pub(crate) thread_source: Option, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) model: Option, pub(crate) model_provider: String, pub(crate) sandbox_policy: Option<&'static str>, @@ -390,6 +393,9 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, pub(crate) result: TurnSteerResult, pub(crate) rejection_reason: Option, @@ -524,25 +530,6 @@ pub(crate) fn codex_plugin_used_metadata( } } -pub(crate) fn codex_turn_steer_event_params( - app_server_client: CodexAppServerClientMetadata, - runtime: CodexRuntimeMetadata, - tracking: &TrackEventsContext, - turn_steer: CodexTurnSteerEvent, -) -> CodexTurnSteerEventParams { - CodexTurnSteerEventParams { - thread_id: tracking.thread_id.clone(), - expected_turn_id: turn_steer.expected_turn_id, - accepted_turn_id: turn_steer.accepted_turn_id, - app_server_client, - runtime, - num_input_images: turn_steer.num_input_images, - result: turn_steer.result, - rejection_reason: turn_steer.rejection_reason, - created_at: turn_steer.created_at, - } -} - pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { match thread_source { SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 352ea1c892..772a091b15 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -8,6 +8,7 @@ use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnSteerEventParams; use crate::events::CodexTurnSteerEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; @@ -21,7 +22,6 @@ use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; -use crate::events::codex_turn_steer_event_params; use crate::events::plugin_state_event_type; use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; @@ -32,7 +32,6 @@ use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; -use crate::facts::CodexTurnSteerEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; @@ -40,7 +39,6 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; -use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -86,12 +84,16 @@ struct ConnectionState { #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, + initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, } impl ThreadMetadataState { - fn from_session_source(session_source: &SessionSource) -> Self { + fn from_thread_metadata( + session_source: &SessionSource, + initialization_mode: ThreadInitializationMode, + ) -> Self { let (subagent_source, parent_thread_id) = match session_source { SessionSource::SubAgent(subagent_source) => ( Some(subagent_source_name(subagent_source)), @@ -106,6 +108,7 @@ impl ThreadMetadataState { }; Self { thread_source: thread_source_name(session_source), + initialization_mode, subagent_source, parent_thread_id, } @@ -657,7 +660,8 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = ThreadMetadataState::from_session_source(&thread_source); + let thread_metadata = + ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode); self.thread_connections .insert(thread_id.clone(), connection_id); self.thread_metadata @@ -759,30 +763,29 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let tracking = TrackEventsContext { - model_slug: String::new(), - thread_id: pending_request.thread_id, - turn_id: accepted_turn_id - .as_deref() - .unwrap_or(pending_request.expected_turn_id.as_str()) - .to_string(), - }; - let turn_steer = CodexTurnSteerEvent { - expected_turn_id: Some(pending_request.expected_turn_id), - accepted_turn_id, - num_input_images: pending_request.num_input_images, - result, - rejection_reason, - created_at: pending_request.created_at, + 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" + ); + return; }; out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest { event_type: "codex_turn_steer_event", - event_params: codex_turn_steer_event_params( - connection_state.app_server_client.clone(), - connection_state.runtime.clone(), - &tracking, - turn_steer, - ), + event_params: CodexTurnSteerEventParams { + thread_id: pending_request.thread_id, + expected_turn_id: Some(pending_request.expected_turn_id), + accepted_turn_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source.map(str::to_string), + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + num_input_images: pending_request.num_input_images, + result, + rejection_reason, + created_at: pending_request.created_at, + }, })); } @@ -807,6 +810,24 @@ impl AnalyticsReducer { ) }); 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" + ); return; }; out.push(TrackEventRequest::TurnEvent(Box::new( @@ -817,6 +838,7 @@ impl AnalyticsReducer { runtime, turn_id.to_string(), turn_state, + thread_metadata, ), }, ))); @@ -829,6 +851,7 @@ fn codex_turn_event_params( runtime: CodexRuntimeMetadata, turn_id: String, turn_state: &TurnState, + thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( turn_state.thread_id.clone(), @@ -867,6 +890,10 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, + thread_source: thread_metadata.thread_source.map(str::to_string), + initialization_mode: thread_metadata.initialization_mode, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), 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 ea3ba02b46..694d0a6eef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -395,6 +395,17 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); assert_eq!(event["event_params"]["num_input_images"], 1); assert_eq!(event["event_params"]["status"], "completed"); assert!(event["event_params"]["started_at"].as_u64().is_some()); From 810bd9d03798922e8b8c5c4a0a90fd0f30b181a1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 14:25:26 -0700 Subject: [PATCH 341/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 93 +++++++--- codex-rs/core/src/guardian/review_session.rs | 178 ++++++++++++++++--- 2 files changed, 217 insertions(+), 54 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 495fba06a1..c9d26ed50e 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -254,6 +258,24 @@ struct GuardianReviewMetadataFields { time_to_first_token_ms: Option, } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: false, + token_usage: metadata.token_usage, + time_to_first_token_ms: None, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -442,8 +464,8 @@ async fn run_guardian_review( let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -479,8 +501,8 @@ async fn run_guardian_review( ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -511,8 +533,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -543,8 +565,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -596,8 +618,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -791,7 +813,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -842,10 +867,12 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match session + let (session_outcome, session_metadata) = session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), @@ -860,25 +887,37 @@ pub(super) async fn run_guardian_review_session( personality: turn.personality, external_cancel, }) - .await - { + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index f6cd4d02b2..c33e4ac6f2 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -57,10 +59,21 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) token_usage: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -100,6 +113,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -266,10 +294,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -303,16 +335,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -320,9 +353,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -350,20 +386,25 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - run_review_on_session(trunk.as_ref(), ¶ms, deadline).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = + run_review_on_session(trunk.as_ref(), ¶ms, guardian_session_kind, deadline).await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -460,7 +501,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -479,20 +523,26 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await; + let (outcome, _, metadata) = run_review_on_session( + review_session.as_ref(), + ¶ms, + GuardianReviewSessionKind::EphemeralForked, + deadline, + ) + .await; if let Some(review_session) = self.take_active_ephemeral(&review_session).await { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -539,8 +589,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -555,6 +610,14 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -579,6 +642,8 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -598,29 +663,45 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -649,7 +730,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -658,7 +739,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -668,7 +749,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -680,18 +761,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -699,6 +782,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -950,4 +1034,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } From 3371df54389a3e9fd118743e16707f1e4843f5f1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 17:17:24 -0700 Subject: [PATCH 342/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 +++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 +++--- codex-rs/core/src/guardian/tests.rs | 87 +++++++++++++++++-- 6 files changed, 158 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index c9cc9d9fa4..bba3bc1c4e 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -168,32 +168,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -386,10 +403,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f5926a0db5..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -64,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index c9d26ed50e..112a72455b 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -268,7 +268,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index c33e4ac6f2..1965c30751 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -71,6 +71,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -616,6 +617,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -642,6 +644,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -663,8 +666,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -674,16 +678,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index b6ba6349f4..26261beba2 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -571,11 +571,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 2bb9f30a6ade94e51f2c17ff47c2ffe28b438bf0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 17:17:24 -0700 Subject: [PATCH 343/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 18 files changed, 108 insertions(+), 3 deletions(-) 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 d2296c75c5..b93f8b662f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1330,6 +1330,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1404,6 +1405,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1727,6 +1729,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2233,6 +2236,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2270,6 +2274,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2322,6 +2327,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2359,6 +2365,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2533,6 +2540,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2551,6 +2559,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2562,6 +2571,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2596,6 +2606,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2652,6 +2663,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2897,6 +2909,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2956,6 +2969,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3007,6 +3021,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3055,6 +3070,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 8d0d40ff94..9313ccbed9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2996,6 +2996,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5648fffd7c..d592bc5f4b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1446,6 +1446,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1631,6 +1632,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1656,6 +1658,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1733,6 +1736,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1751,6 +1755,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1778,6 +1783,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1824,6 +1830,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1847,6 +1854,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1870,6 +1878,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4830,6 +4839,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() turn_id, last_agent_message: None, .. + time_to_first_token_ms: None, }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 112a72455b..06e042b815 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -270,7 +270,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 1965c30751..984181ed1f 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -73,6 +73,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -619,6 +620,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -691,8 +693,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -736,6 +743,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -761,6 +769,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..72c008aa19 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -527,11 +527,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cb5dea34f0..536b8b000e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2041,6 +2041,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0bd78f353a..e3e32adf7b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -759,6 +759,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -790,6 +791,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d271fc0076..b23074f791 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -652,6 +652,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -697,6 +698,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -764,6 +766,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -844,6 +847,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1344,6 +1348,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 083cb8069a..47332bfcf2 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 1c43d2e91d..b4726b7cf9 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -943,6 +943,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1259,6 +1260,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2199,6 +2201,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From fa813617b56952bcd9c08c2bbdc6464f3c3ddc4c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 8 Apr 2026 21:03:26 -0700 Subject: [PATCH 344/383] [codex-analytics] add tool item event schemas --- .../analytics/src/analytics_client_tests.rs | 154 +++++++++++ codex-rs/analytics/src/events.rs | 239 ++++++++++++++++++ 2 files changed, 393 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..8fbb28fb49 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; use crate::events::ThreadInitializationMode; 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_plugin_metadata; @@ -346,6 +351,155 @@ 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: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::Agent, + command_execution_family: CommandExecutionFamily::Shell, + process_id: Some("pid-1".to_string()), + exit_code: Some(0), + command_action_count: 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": 123, + "completed_at": 125, + "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", + "process_id": "pid-1", + "exit_code": 0, + "command_action_count": 1 + } + }) + ); +} + +#[test] +fn command_execution_event_allows_null_thread_denormalization() { + 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: None, + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::Agent, + command_execution_family: CommandExecutionFamily::Shell, + process_id: None, + exit_code: Some(0), + command_action_count: 0, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!(payload["event_params"]["thread_source"], json!(null)); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..270a037718 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,14 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + #[allow(dead_code)] + CommandExecution(CodexCommandExecutionEventRequest), + FileChange(CodexFileChangeEventRequest), + McpToolCall(CodexMcpToolCallEventRequest), + DynamicToolCall(CodexDynamicToolCallEventRequest), + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + WebSearch(CodexWebSearchEventRequest), + ImageGeneration(CodexImageGenerationEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +107,237 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +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: u64, + pub(crate) completed_at: 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, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionFamily { + Shell, + UserShell, + UnifiedExec, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionSourceKind { + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CollabAgentToolKind { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[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: CommandExecutionSourceKind, + pub(crate) command_execution_family: CommandExecutionFamily, + pub(crate) process_id: Option, + pub(crate) exit_code: Option, + pub(crate) command_action_count: u64, +} + +#[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, +} + +#[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) collab_agent_tool: CollabAgentToolKind, + pub(crate) sender_thread_id: String, + pub(crate) receiver_thread_count: u64, + pub(crate) receiver_thread_ids: Vec, + pub(crate) requested_model: Option, + pub(crate) requested_reasoning_effort: Option, + pub(crate) agent_state_count: u64, + pub(crate) completed_agent_count: u64, + pub(crate) failed_agent_count: u64, +} + +#[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, From 94b0702e1460a7d949271683e467cf650801be44 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 13 Apr 2026 22:53:01 -0700 Subject: [PATCH 345/383] [codex-analytics] emit tool item events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 133 ++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 20 +- codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 741 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 891 insertions(+), 26 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 8fbb28fb49..6f1ac70e78 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3,6 +3,8 @@ 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::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -39,12 +41,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +127,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "item-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: Some("pid-1".to_string()), + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -603,6 +655,87 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + assert_eq!(payload[0]["event_params"]["process_id"], "pid-1"); + 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"], json!(null)); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d2d75a31ea..46c6ea2890 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -250,6 +251,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 270a037718..c5633ffabf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,7 +37,6 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), - #[allow(dead_code)] CommandExecution(CodexCommandExecutionEventRequest), FileChange(CodexFileChangeEventRequest), McpToolCall(CodexMcpToolCallEventRequest), @@ -110,21 +109,10 @@ pub(crate) struct ThreadInitializedEvent { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] -pub(crate) enum ToolKind { - Shell, - UnifiedExec, - ApplyPatch, - Mcp, - Dynamic, - Other, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -#[allow(dead_code)] -pub(crate) enum ToolCallFinalReviewOutcome { +pub(crate) enum ToolItemFinalApprovalOutcome { NotNeeded, + ConfigAllowed, + PolicyForbidden, GuardianApproved, GuardianDenied, GuardianAborted, @@ -137,7 +125,6 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemTerminalStatus { Completed, Failed, @@ -148,7 +135,6 @@ pub(crate) enum ToolItemTerminalStatus { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..83d83d1f1a 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,15 +2,37 @@ 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::CodexDynamicToolCallEventParams; +use crate::events::CodexDynamicToolCallEventRequest; +use crate::events::CodexFileChangeEventParams; +use crate::events::CodexFileChangeEventRequest; +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::CodexWebSearchEventParams; +use crate::events::CodexWebSearchEventRequest; +use crate::events::CollabAgentToolKind; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; 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_plugin_metadata; use crate::events::codex_plugin_used_metadata; @@ -27,7 +49,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; +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; @@ -36,10 +71,14 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + threads: HashMap, + tool_items: HashMap, } struct ConnectionState { @@ -47,6 +86,18 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +#[derive(Clone, Default)] +struct ThreadMetadataState { + thread_source: Option<&'static str>, + subagent_source: Option, + parent_thread_id: Option, +} + +struct ToolItemState { + connection_id: u64, + started_at: u64, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +134,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -137,9 +193,16 @@ impl AnalyticsReducer { input: SubAgentThreadStartedInput, out: &mut Vec, ) { - out.push(TrackEventRequest::ThreadInitialized( - subagent_thread_started_event_request(input), - )); + let event = subagent_thread_started_event_request(input); + self.threads.insert( + event.event_params.thread_id.clone(), + ThreadMetadataState { + thread_source: event.event_params.thread_source, + subagent_source: event.event_params.subagent_source.clone(), + parent_thread_id: event.event_params.parent_thread_id.clone(), + }, + ); + out.push(TrackEventRequest::ThreadInitialized(event)); } async fn ingest_skill_invoked( @@ -264,16 +327,26 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; + let thread_id = thread.id.clone(); + let thread_source = thread_source_name(&thread_source); + self.threads.insert( + thread_id.clone(), + ThreadMetadataState { + thread_source, + subagent_source: None, + parent_thread_id: None, + }, + ); out.push(TrackEventRequest::ThreadInitialized( ThreadInitializedEvent { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { - thread_id: thread.id, + thread_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), model, ephemeral: thread.ephemeral, - thread_source: thread_source_name(&thread_source), + thread_source, initialization_mode, subagent_source: None, parent_thread_id: None, @@ -282,6 +355,662 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(item_id) = tool_item_id(¬ification.item) { + self.tool_items + .entry(item_id.to_string()) + .or_insert_with(|| ToolItemState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tool_item_id(¬ification.item) else { + return; + }; + let Some(started) = self.tool_items.remove(item_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + started.started_at, + completed_at, + connection_state, + self.threads.get(¬ification.thread_id), + ) { + out.push(event); + } + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +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, + started_at: u64, + completed_at: u64, + connection_state: &ConnectionState, + thread_metadata: Option<&ThreadMetadataState>, +) -> Option { + match item { + ThreadItem::CommandExecution { + id, + process_id, + source, + status, + command_actions, + exit_code, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: command_execution_source_kind(*source), + command_execution_family: command_execution_family(*source), + process_id: process_id.clone(), + exit_code: *exit_code, + command_action_count: usize_to_u64(command_actions.len()), + }, + }, + )) + } + ThreadItem::FileChange { + id, + changes, + status, + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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(), + }, + }, + )) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + content_items, + success, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + collab_agent_tool: collab_agent_tool_kind(tool), + sender_thread_id: sender_thread_id.clone(), + receiver_thread_count: usize_to_u64(receiver_thread_ids.len()), + receiver_thread_ids: receiver_thread_ids.clone(), + requested_model: model.clone(), + requested_reasoning_effort: reasoning_effort + .as_ref() + .and_then(serialize_enum_as_string), + agent_state_count: usize_to_u64(agents_states.len()), + completed_agent_count: usize_to_u64( + agents_states + .values() + .filter(|state| state.status == CollabAgentStatus::Completed) + .count(), + ), + failed_agent_count: usize_to_u64( + agents_states + .values() + .filter(|state| { + matches!( + state.status, + CollabAgentStatus::Errored + | CollabAgentStatus::Shutdown + | CollabAgentStatus::NotFound + ) + }) + .count(), + ), + }, + }, + )) + } + ThreadItem::WebSearch { id, query, action } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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: u64, + completed_at: u64, + connection_state: &'a ConnectionState, + thread_metadata: Option<&'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.cloned().unwrap_or_default(); + 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, + parent_thread_id: thread_metadata.parent_thread_id, + tool_name, + started_at: context.started_at, + completed_at: Some(context.completed_at), + duration_ms: outcome.duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + } +} + +fn completed_duration_ms( + item_duration_ms: Option, + started_at: u64, + completed_at: u64, +) -> Option { + item_duration_ms.or_else(|| { + completed_at + .checked_sub(started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }) +} + +fn command_execution_source_kind(source: CommandExecutionSource) -> CommandExecutionSourceKind { + match source { + CommandExecutionSource::Agent => CommandExecutionSourceKind::Agent, + CommandExecutionSource::UserShell => CommandExecutionSourceKind::UserShell, + CommandExecutionSource::UnifiedExecStartup => { + CommandExecutionSourceKind::UnifiedExecStartup + } + CommandExecutionSource::UnifiedExecInteraction => { + CommandExecutionSourceKind::UnifiedExecInteraction + } + } +} + +fn command_execution_family(source: CommandExecutionSource) -> CommandExecutionFamily { + match source { + CommandExecutionSource::Agent => CommandExecutionFamily::Shell, + CommandExecutionSource::UserShell => CommandExecutionFamily::UserShell, + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => CommandExecutionFamily::UnifiedExec, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::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", + } +} + +fn collab_agent_tool_kind(tool: &CollabAgentTool) -> CollabAgentToolKind { + match tool { + CollabAgentTool::SpawnAgent => CollabAgentToolKind::SpawnAgent, + CollabAgentTool::SendInput => CollabAgentToolKind::SendInput, + CollabAgentTool::ResumeAgent => CollabAgentToolKind::ResumeAgent, + CollabAgentTool::Wait => CollabAgentToolKind::Wait, + CollabAgentTool::CloseAgent => CollabAgentToolKind::CloseAgent, + } +} + +#[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 serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 3bf623bd907251b065845e1b3c3ef194e5201501 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:50:37 -0700 Subject: [PATCH 346/383] [codex-analytics] guardian review analytics schema polishing --- .../analytics/src/analytics_client_tests.rs | 136 ++++++++++++++++++ codex-rs/analytics/src/events.rs | 83 +++-------- codex-rs/analytics/src/lib.rs | 14 +- codex-rs/core/src/compact.rs | 10 +- codex-rs/protocol/src/approvals.rs | 8 ++ codex-rs/protocol/src/protocol.rs | 1 + 6 files changed, 178 insertions(+), 74 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e5aca39b8e..f6bb2b799b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -7,6 +7,12 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::GuardianApprovalRequestSource; +use crate::events::GuardianReviewDecision; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewFailureReason; +use crate::events::GuardianReviewTerminalStatus; +use crate::events::GuardianReviewedAction; use crate::events::ThreadInitializationMode; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; @@ -57,6 +63,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; @@ -685,6 +692,135 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + 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: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-guardian", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(Box::new( + GuardianReviewEventParams { + thread_id: "thread-guardian".to_string(), + turn_id: "turn-guardian".to_string(), + review_id: "review-guardian".to_string(), + target_item_id: None, + approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent, + reviewed_action: GuardianReviewedAction::NetworkAccess { + protocol: NetworkApprovalProtocol::Https, + port: 443, + }, + reviewed_action_truncated: false, + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + review_timeout_ms: 90_000, + tool_call_count: None, + time_to_first_token_ms: None, + completion_latency_ms: Some(90_000), + started_at: 100, + completed_at: Some(190), + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: 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_guardian_review"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); + assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); + assert_eq!(payload[0]["event_params"]["target_item_id"], json!(null)); + assert_eq!( + payload[0]["event_params"]["approval_request_source"], + "delegated_subagent" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["type"], + "network_access" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["protocol"], + "https" + ); + assert_eq!(payload[0]["event_params"]["reviewed_action"]["port"], 443); + assert!(payload[0]["event_params"].get("retry_reason").is_none()); + assert!(payload[0]["event_params"].get("rationale").is_none()); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("target") + .is_none() + ); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("host") + .is_none() + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out"); + assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout"); + assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 618dd8ffeb..c0e093b0e9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,11 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionImplementation; +use crate::facts::CompactionPhase; +use crate::facts::CompactionReason; +use crate::facts::CompactionStatus; +use crate::facts::CompactionStrategy; +use crate::facts::CompactionTrigger; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -9,6 +15,10 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianCommandSource; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -147,31 +157,6 @@ pub enum GuardianReviewSessionKind { EphemeralForked, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewRiskLevel { - Low, - Medium, - High, - Critical, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewOutcome { - Allow, - Deny, -} - #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum GuardianApprovalRequestSource { @@ -186,36 +171,21 @@ pub enum GuardianApprovalRequestSource { #[serde(tag = "type", rename_all = "snake_case")] pub enum GuardianReviewedAction { Shell { - command: Vec, - command_display: String, - cwd: String, sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, }, UnifiedExec { - command: Vec, - command_display: String, - cwd: String, sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, tty: bool, }, Execve { source: GuardianCommandSource, program: String, - argv: Vec, - cwd: String, additional_permissions: Option, }, - ApplyPatch { - cwd: String, - files: Vec, - }, + ApplyPatch {}, NetworkAccess { - target: String, - host: String, protocol: NetworkApprovalProtocol, port: u16, }, @@ -228,37 +198,28 @@ pub enum GuardianReviewedAction { }, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GuardianCommandSource { - Shell, - UnifiedExec, -} - #[derive(Clone, Serialize)] pub struct GuardianReviewEventParams { pub thread_id: String, pub turn_id: String, pub review_id: String, - pub target_item_id: String, - pub retry_reason: Option, + pub target_item_id: Option, pub approval_request_source: GuardianApprovalRequestSource, pub reviewed_action: GuardianReviewedAction, pub reviewed_action_truncated: bool, pub decision: GuardianReviewDecision, pub terminal_status: GuardianReviewTerminalStatus, pub failure_reason: Option, - pub risk_level: Option, - pub user_authorization: Option, - pub outcome: Option, - pub rationale: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, pub guardian_thread_id: Option, pub guardian_session_kind: Option, pub guardian_model: Option, pub guardian_reasoning_effort: Option, pub had_prior_review_context: Option, pub review_timeout_ms: u64, - pub tool_call_count: u64, + pub tool_call_count: Option, pub time_to_first_token_ms: Option, pub completion_latency_ms: Option, pub started_at: u64, @@ -310,12 +271,12 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) thread_source: Option<&'static str>, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, - pub(crate) trigger: crate::facts::CompactionTrigger, - pub(crate) reason: crate::facts::CompactionReason, - pub(crate) implementation: crate::facts::CompactionImplementation, - pub(crate) phase: crate::facts::CompactionPhase, - pub(crate) strategy: crate::facts::CompactionStrategy, - pub(crate) status: crate::facts::CompactionStatus, + pub(crate) trigger: CompactionTrigger, + pub(crate) reason: CompactionReason, + pub(crate) implementation: CompactionImplementation, + pub(crate) phase: CompactionPhase, + pub(crate) strategy: CompactionStrategy, + pub(crate) status: CompactionStatus, pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 9b4cc1e9bc..8c07a26209 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -3,18 +3,17 @@ mod events; mod facts; mod reducer; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; -pub use events::GuardianCommandSource; pub use events::GuardianReviewDecision; pub use events::GuardianReviewEventParams; pub use events::GuardianReviewFailureReason; -pub use events::GuardianReviewOutcome; -pub use events::GuardianReviewRiskLevel; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; -pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; @@ -32,3 +31,10 @@ pub use facts::build_track_events_context; #[cfg(test)] mod analytics_client_tests; + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index e132ce0213..59fb45454d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,7 +1,5 @@ use std::sync::Arc; use std::time::Instant; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use crate::Prompt; use crate::client::ModelClientSession; @@ -19,6 +17,7 @@ use codex_analytics::CompactionReason; use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; +use codex_analytics::now_unix_seconds; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::error::CodexErr; @@ -372,13 +371,6 @@ pub(crate) fn compaction_status_from_result(result: &CodexResult) -> Compa } } -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - pub fn content_items_to_text(content: &[ContentItem]) -> Option { let mut pieces = Vec::new(); for item in content { diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index b154946f42..366f394433 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -99,6 +99,14 @@ pub enum GuardianUserAuthorization { High, } +/// Final allow/deny outcome returned by the guardian reviewer. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum GuardianAssessmentOutcome { + Allow, + Deny, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum GuardianAssessmentStatus { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1fc707469e..cb5dea34f0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -67,6 +67,7 @@ pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::GuardianAssessmentAction; pub use crate::approvals::GuardianAssessmentDecisionSource; pub use crate::approvals::GuardianAssessmentEvent; +pub use crate::approvals::GuardianAssessmentOutcome; pub use crate::approvals::GuardianAssessmentStatus; pub use crate::approvals::GuardianCommandSource; pub use crate::approvals::GuardianRiskLevel; From e7ae68e1451e7e4dd0f8c5356c49fe9075d538df Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:50:37 -0700 Subject: [PATCH 347/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/guardian/mod.rs | 11 +- codex-rs/core/src/guardian/review.rs | 457 ++++++++++++++++++++++++++- 3 files changed, 448 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 55b3619e11..d7911ff349 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -40,7 +41,7 @@ use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; +use crate::guardian::review_approval_request_with_source_and_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -747,12 +748,13 @@ fn spawn_guardian_review( let _ = tx.send(ReviewDecision::Denied); return; }; - let decision = runtime.block_on(review_approval_request_with_cancel( + let decision = runtime.block_on(review_approval_request_with_source_and_cancel( &session, &turn, review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 67e9a828ee..f5926a0db5 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -19,6 +19,7 @@ mod review_session; use std::time::Duration; use codex_protocol::protocol::GuardianAssessmentDecisionSource; +use codex_protocol::protocol::GuardianAssessmentOutcome; use serde::Deserialize; use serde::Serialize; @@ -30,7 +31,9 @@ pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; +pub(crate) use review::review_approval_request_with_source_and_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review_session::GuardianReviewSessionManager; @@ -45,14 +48,6 @@ const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const TRUNCATION_TAG: &str = "truncated"; -/// Final allow/deny outcome returned by the guardian reviewer. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum GuardianAssessmentOutcome { - Allow, - Deny, -} - /// Structured output contract that the guardian reviewer must satisfy. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct GuardianAssessment { diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 861d3576cd..215ab2b159 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,14 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -10,12 +19,14 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -76,10 +87,34 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { Completed(anyhow::Result), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -89,6 +124,166 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome, + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +311,24 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +350,27 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at: now_unix_seconds(), + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +396,138 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, ) .await; + let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(Ok(assessment)) => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if matches!(assessment.outcome, GuardianAssessmentOutcome::Allow) { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if matches!( + assessment.outcome, + GuardianAssessmentOutcome::Allow + ) { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err)) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { + let metadata = GuardianReviewMetadataFields::default(); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +555,28 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -309,11 +674,34 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, ) .await } +pub(crate) async fn review_approval_request_with_source_and_cancel( + session: &Arc, + turn: &Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + cancel_token: CancellationToken, +) -> ReviewDecision { + run_guardian_review( + Arc::clone(session), + Arc::clone(turn), + review_id, + request, + retry_reason, + approval_request_source, + Some(cancel_token), + ) + .await +} + +#[cfg(test)] pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, @@ -328,6 +716,7 @@ pub(crate) async fn review_approval_request_with_cancel( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, Some(cancel_token), ) .await @@ -358,7 +747,9 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + } }, None => None, }; @@ -408,7 +799,7 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), }; match session @@ -428,15 +819,49 @@ pub(super) async fn run_guardian_review_session( }) .await { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), + Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + } + } + None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) } GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, } } + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); + } +} From 1e05f085288f32b52e44b86cbaa713957b956678 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:50:37 -0700 Subject: [PATCH 348/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 93 +++++++--- codex-rs/core/src/guardian/review_session.rs | 178 ++++++++++++++++--- 2 files changed, 217 insertions(+), 54 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 215ab2b159..60dbde6ecd 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -220,6 +224,24 @@ struct GuardianReviewMetadataFields { time_to_first_token_ms: Option, } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: false, + token_usage: metadata.token_usage, + time_to_first_token_ms: None, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -404,8 +426,8 @@ async fn run_guardian_review( let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -440,8 +462,8 @@ async fn run_guardian_review( ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -471,8 +493,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -502,8 +524,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -554,8 +576,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -748,7 +770,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -799,10 +824,12 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match session + let (session_outcome, session_metadata) = session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), @@ -817,25 +844,37 @@ pub(super) async fn run_guardian_review_session( personality: turn.personality, external_cancel, }) - .await - { + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index f6cd4d02b2..c33e4ac6f2 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -57,10 +59,21 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) token_usage: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -100,6 +113,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -266,10 +294,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -303,16 +335,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -320,9 +353,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -350,20 +386,25 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - run_review_on_session(trunk.as_ref(), ¶ms, deadline).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = + run_review_on_session(trunk.as_ref(), ¶ms, guardian_session_kind, deadline).await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -460,7 +501,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -479,20 +523,26 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await; + let (outcome, _, metadata) = run_review_on_session( + review_session.as_ref(), + ¶ms, + GuardianReviewSessionKind::EphemeralForked, + deadline, + ) + .await; if let Some(review_session) = self.take_active_ephemeral(&review_session).await { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -539,8 +589,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -555,6 +610,14 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -579,6 +642,8 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -598,29 +663,45 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -649,7 +730,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -658,7 +739,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -668,7 +749,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -680,18 +761,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -699,6 +782,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -950,4 +1034,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } From 15ca8c08c9416724639410dd8f4bd371eaf0e0a6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:50:37 -0700 Subject: [PATCH 349/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 +++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 +++--- codex-rs/core/src/guardian/tests.rs | 87 +++++++++++++++++-- 6 files changed, 158 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index c9cc9d9fa4..bba3bc1c4e 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -168,32 +168,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -386,10 +403,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f5926a0db5..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -64,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 60dbde6ecd..0bc1d08ae4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -234,7 +234,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index c33e4ac6f2..1965c30751 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -71,6 +71,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -616,6 +617,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -642,6 +644,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -663,8 +666,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -674,16 +678,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index b6ba6349f4..26261beba2 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -571,11 +571,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From a22658d75fe5de97767ab2df9dbf8bfd31a79eee Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:50:37 -0700 Subject: [PATCH 350/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 18 files changed, 108 insertions(+), 3 deletions(-) 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 d2296c75c5..b93f8b662f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1330,6 +1330,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1404,6 +1405,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1727,6 +1729,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2233,6 +2236,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2270,6 +2274,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2322,6 +2327,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2359,6 +2365,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2533,6 +2540,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2551,6 +2559,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2562,6 +2571,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2596,6 +2606,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2652,6 +2663,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2897,6 +2909,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2956,6 +2969,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3007,6 +3021,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3055,6 +3070,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 8d0d40ff94..9313ccbed9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2996,6 +2996,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5648fffd7c..d592bc5f4b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1446,6 +1446,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1631,6 +1632,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1656,6 +1658,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1733,6 +1736,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1751,6 +1755,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1778,6 +1783,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1824,6 +1830,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1847,6 +1854,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1870,6 +1878,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4830,6 +4839,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() turn_id, last_agent_message: None, .. + time_to_first_token_ms: None, }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 0bc1d08ae4..e6db92b41d 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -236,7 +236,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 1965c30751..984181ed1f 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -73,6 +73,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -619,6 +620,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -691,8 +693,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -736,6 +743,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -761,6 +769,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b46db8e98f..72c008aa19 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -527,11 +527,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cb5dea34f0..536b8b000e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2041,6 +2041,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0bd78f353a..e3e32adf7b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -759,6 +759,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -790,6 +791,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d271fc0076..b23074f791 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -652,6 +652,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -697,6 +698,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -764,6 +766,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -844,6 +847,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1344,6 +1348,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 083cb8069a..47332bfcf2 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 1c43d2e91d..b4726b7cf9 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -943,6 +943,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1259,6 +1260,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2199,6 +2201,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From 96d7f9649b3b1a39ec8b5fe230d7dfba528700b0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:59:52 -0700 Subject: [PATCH 351/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 93 +++++++--- codex-rs/core/src/guardian/review_session.rs | 178 ++++++++++++++++--- 2 files changed, 217 insertions(+), 54 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 215ab2b159..60dbde6ecd 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -220,6 +224,24 @@ struct GuardianReviewMetadataFields { time_to_first_token_ms: Option, } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: false, + token_usage: metadata.token_usage, + time_to_first_token_ms: None, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -404,8 +426,8 @@ async fn run_guardian_review( let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -440,8 +462,8 @@ async fn run_guardian_review( ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -471,8 +493,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -502,8 +524,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -554,8 +576,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -748,7 +770,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -799,10 +824,12 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match session + let (session_outcome, session_metadata) = session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), @@ -817,25 +844,37 @@ pub(super) async fn run_guardian_review_session( personality: turn.personality, external_cancel, }) - .await - { + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 08825cfde2..0384e27f12 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -58,10 +60,21 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) token_usage: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -101,6 +114,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -267,10 +295,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -304,16 +336,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -321,9 +354,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -351,20 +387,25 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - run_review_on_session(trunk.as_ref(), ¶ms, deadline).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = + run_review_on_session(trunk.as_ref(), ¶ms, guardian_session_kind, deadline).await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -461,7 +502,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -480,20 +524,26 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await; + let (outcome, _, metadata) = run_review_on_session( + review_session.as_ref(), + ¶ms, + GuardianReviewSessionKind::EphemeralForked, + deadline, + ) + .await; if let Some(review_session) = self.take_active_ephemeral(&review_session).await { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -540,8 +590,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -556,6 +611,14 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -580,6 +643,8 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -599,29 +664,45 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -650,7 +731,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -659,7 +740,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -669,7 +750,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -681,18 +762,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -700,6 +783,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -951,4 +1035,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } From 897b8c7f880dc97f36ec2476a79833b0b345cea5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:59:52 -0700 Subject: [PATCH 352/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 +++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 +++--- codex-rs/core/src/guardian/tests.rs | 87 +++++++++++++++++-- 6 files changed, 158 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..b3fb7f71dc 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -167,32 +167,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -382,10 +399,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f5926a0db5..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -64,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 60dbde6ecd..0bc1d08ae4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -234,7 +234,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 0384e27f12..7a043dc22d 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -72,6 +72,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -617,6 +618,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -643,6 +645,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -664,8 +667,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -675,16 +679,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 5e44aa972a..daa9453df4 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -584,11 +584,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 7c56f2c334529c434350bc6b135246e6c29254e0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:59:52 -0700 Subject: [PATCH 353/383] [codex-analytics] guardian review analytics schema polishing --- .../analytics/src/analytics_client_tests.rs | 136 ++++++++++++++++++ codex-rs/analytics/src/events.rs | 83 +++-------- codex-rs/analytics/src/lib.rs | 4 - codex-rs/protocol/src/approvals.rs | 8 ++ codex-rs/protocol/src/protocol.rs | 1 + 5 files changed, 167 insertions(+), 65 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb46674156..ea5893c60e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,6 +8,12 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::GuardianApprovalRequestSource; +use crate::events::GuardianReviewDecision; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewFailureReason; +use crate::events::GuardianReviewTerminalStatus; +use crate::events::GuardianReviewedAction; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -78,6 +84,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; @@ -1043,6 +1050,135 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + 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: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-guardian", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(Box::new( + GuardianReviewEventParams { + thread_id: "thread-guardian".to_string(), + turn_id: "turn-guardian".to_string(), + review_id: "review-guardian".to_string(), + target_item_id: None, + approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent, + reviewed_action: GuardianReviewedAction::NetworkAccess { + protocol: NetworkApprovalProtocol::Https, + port: 443, + }, + reviewed_action_truncated: false, + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + review_timeout_ms: 90_000, + tool_call_count: None, + time_to_first_token_ms: None, + completion_latency_ms: Some(90_000), + started_at: 100, + completed_at: Some(190), + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: 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_guardian_review"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); + assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); + assert_eq!(payload[0]["event_params"]["target_item_id"], json!(null)); + assert_eq!( + payload[0]["event_params"]["approval_request_source"], + "delegated_subagent" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["type"], + "network_access" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["protocol"], + "https" + ); + assert_eq!(payload[0]["event_params"]["reviewed_action"]["port"], 443); + assert!(payload[0]["event_params"].get("retry_reason").is_none()); + assert!(payload[0]["event_params"].get("rationale").is_none()); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("target") + .is_none() + ); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("host") + .is_none() + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out"); + assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout"); + assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eb312da140..ea232df071 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,11 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionImplementation; +use crate::facts::CompactionPhase; +use crate::facts::CompactionReason; +use crate::facts::CompactionStatus; +use crate::facts::CompactionStrategy; +use crate::facts::CompactionTrigger; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -15,6 +21,10 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianCommandSource; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -146,31 +156,6 @@ pub enum GuardianReviewSessionKind { EphemeralForked, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewRiskLevel { - Low, - Medium, - High, - Critical, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewOutcome { - Allow, - Deny, -} - #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum GuardianApprovalRequestSource { @@ -185,36 +170,21 @@ pub enum GuardianApprovalRequestSource { #[serde(tag = "type", rename_all = "snake_case")] pub enum GuardianReviewedAction { Shell { - command: Vec, - command_display: String, - cwd: String, sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, }, UnifiedExec { - command: Vec, - command_display: String, - cwd: String, sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, tty: bool, }, Execve { source: GuardianCommandSource, program: String, - argv: Vec, - cwd: String, additional_permissions: Option, }, - ApplyPatch { - cwd: String, - files: Vec, - }, + ApplyPatch {}, NetworkAccess { - target: String, - host: String, protocol: NetworkApprovalProtocol, port: u16, }, @@ -227,37 +197,28 @@ pub enum GuardianReviewedAction { }, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GuardianCommandSource { - Shell, - UnifiedExec, -} - #[derive(Clone, Serialize)] pub struct GuardianReviewEventParams { pub thread_id: String, pub turn_id: String, pub review_id: String, - pub target_item_id: String, - pub retry_reason: Option, + pub target_item_id: Option, pub approval_request_source: GuardianApprovalRequestSource, pub reviewed_action: GuardianReviewedAction, pub reviewed_action_truncated: bool, pub decision: GuardianReviewDecision, pub terminal_status: GuardianReviewTerminalStatus, pub failure_reason: Option, - pub risk_level: Option, - pub user_authorization: Option, - pub outcome: Option, - pub rationale: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, pub guardian_thread_id: Option, pub guardian_session_kind: Option, pub guardian_model: Option, pub guardian_reasoning_effort: Option, pub had_prior_review_context: Option, pub review_timeout_ms: u64, - pub tool_call_count: u64, + pub tool_call_count: Option, pub time_to_first_token_ms: Option, pub completion_latency_ms: Option, pub started_at: u64, @@ -309,12 +270,12 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) thread_source: Option<&'static str>, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, - pub(crate) trigger: crate::facts::CompactionTrigger, - pub(crate) reason: crate::facts::CompactionReason, - pub(crate) implementation: crate::facts::CompactionImplementation, - pub(crate) phase: crate::facts::CompactionPhase, - pub(crate) strategy: crate::facts::CompactionStrategy, - pub(crate) status: crate::facts::CompactionStatus, + pub(crate) trigger: CompactionTrigger, + pub(crate) reason: CompactionReason, + pub(crate) implementation: CompactionImplementation, + pub(crate) phase: CompactionPhase, + pub(crate) strategy: CompactionStrategy, + pub(crate) status: CompactionStatus, pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1a1a123152..a71fe8574f 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,15 +9,11 @@ use std::time::UNIX_EPOCH; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; -pub use events::GuardianCommandSource; pub use events::GuardianReviewDecision; pub use events::GuardianReviewEventParams; pub use events::GuardianReviewFailureReason; -pub use events::GuardianReviewOutcome; -pub use events::GuardianReviewRiskLevel; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; -pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index db00db9b32..b8839b51b6 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -100,6 +100,14 @@ pub enum GuardianUserAuthorization { High, } +/// Final allow/deny outcome returned by the guardian reviewer. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum GuardianAssessmentOutcome { + Allow, + Deny, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum GuardianAssessmentStatus { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 244f748c82..7d20fadeb4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -67,6 +67,7 @@ pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::GuardianAssessmentAction; pub use crate::approvals::GuardianAssessmentDecisionSource; pub use crate::approvals::GuardianAssessmentEvent; +pub use crate::approvals::GuardianAssessmentOutcome; pub use crate::approvals::GuardianAssessmentStatus; pub use crate::approvals::GuardianCommandSource; pub use crate::approvals::GuardianRiskLevel; From fe59c9662543c7cbd673221e43d09e1c672bfe8f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:59:52 -0700 Subject: [PATCH 354/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 18 files changed, 108 insertions(+), 3 deletions(-) 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 9e94515dd9..a390d33392 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1332,6 +1332,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1406,6 +1407,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1729,6 +1731,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2235,6 +2238,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2272,6 +2276,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2324,6 +2329,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2361,6 +2367,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2535,6 +2542,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2553,6 +2561,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2564,6 +2573,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2598,6 +2608,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2654,6 +2665,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2899,6 +2911,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2958,6 +2971,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3009,6 +3023,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3057,6 +3072,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 4fddee4a11..d5195f5960 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3042,6 +3042,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index bf213a6457..3e491c654d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1381,6 +1381,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1566,6 +1567,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1591,6 +1593,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1668,6 +1671,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1686,6 +1690,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1713,6 +1718,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1759,6 +1765,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1782,6 +1789,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1805,6 +1813,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4767,6 +4776,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() turn_id, last_agent_message: None, .. + time_to_first_token_ms: None, }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 0bc1d08ae4..e6db92b41d 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -236,7 +236,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7a043dc22d..f11e7d97aa 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -74,6 +74,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -620,6 +621,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -692,8 +694,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -737,6 +744,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -762,6 +770,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f17017316e..692a5919a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -535,11 +535,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d20fadeb4..73045ba734 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2057,6 +2057,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 09b8ce06cd..2107aef4b7 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -762,6 +762,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -793,6 +794,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1..261fd47f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1350,6 +1354,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index f3746fdbf0..2cd2790ee6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 e59928ff1c..a294e3aadf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -961,6 +961,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1311,6 +1312,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2251,6 +2253,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From 8de41717a00d6c504956751e3048ba857382697d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 14:59:52 -0700 Subject: [PATCH 355/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/guardian/mod.rs | 11 +- codex-rs/core/src/guardian/review.rs | 457 ++++++++++++++++++++++++++- 3 files changed, 448 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d5af9304af..89e061f527 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -40,7 +41,7 @@ use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; +use crate::guardian::review_approval_request_with_source_and_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -747,12 +748,13 @@ fn spawn_guardian_review( let _ = tx.send(ReviewDecision::Denied); return; }; - let decision = runtime.block_on(review_approval_request_with_cancel( + let decision = runtime.block_on(review_approval_request_with_source_and_cancel( &session, &turn, review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 67e9a828ee..f5926a0db5 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -19,6 +19,7 @@ mod review_session; use std::time::Duration; use codex_protocol::protocol::GuardianAssessmentDecisionSource; +use codex_protocol::protocol::GuardianAssessmentOutcome; use serde::Deserialize; use serde::Serialize; @@ -30,7 +31,9 @@ pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; +pub(crate) use review::review_approval_request_with_source_and_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review_session::GuardianReviewSessionManager; @@ -45,14 +48,6 @@ const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const TRUNCATION_TAG: &str = "truncated"; -/// Final allow/deny outcome returned by the guardian reviewer. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum GuardianAssessmentOutcome { - Allow, - Deny, -} - /// Structured output contract that the guardian reviewer must satisfy. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct GuardianAssessment { diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 861d3576cd..215ab2b159 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,14 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -10,12 +19,14 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -76,10 +87,34 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { Completed(anyhow::Result), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -89,6 +124,166 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome, + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +311,24 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +350,27 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at: now_unix_seconds(), + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +396,138 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, ) .await; + let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(Ok(assessment)) => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if matches!(assessment.outcome, GuardianAssessmentOutcome::Allow) { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if matches!( + assessment.outcome, + GuardianAssessmentOutcome::Allow + ) { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err)) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { + let metadata = GuardianReviewMetadataFields::default(); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +555,28 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -309,11 +674,34 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, ) .await } +pub(crate) async fn review_approval_request_with_source_and_cancel( + session: &Arc, + turn: &Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + cancel_token: CancellationToken, +) -> ReviewDecision { + run_guardian_review( + Arc::clone(session), + Arc::clone(turn), + review_id, + request, + retry_reason, + approval_request_source, + Some(cancel_token), + ) + .await +} + +#[cfg(test)] pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, @@ -328,6 +716,7 @@ pub(crate) async fn review_approval_request_with_cancel( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, Some(cancel_token), ) .await @@ -358,7 +747,9 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + } }, None => None, }; @@ -408,7 +799,7 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), }; match session @@ -428,15 +819,49 @@ pub(super) async fn run_guardian_review_session( }) .await { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), + Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + } + } + None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) } GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, } } + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); + } +} From 850e4a88aaac4427279b86be7ae64eea2a4b7248 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 16:13:15 -0700 Subject: [PATCH 356/383] [codex-analytics] add tool item event schemas --- .../analytics/src/analytics_client_tests.rs | 151 +++++++++++ codex-rs/analytics/src/events.rs | 239 ++++++++++++++++++ 2 files changed, 390 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb511483a4..d9f8884c7f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -6,9 +6,14 @@ use crate::events::CodexAppUsedEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; use crate::events::ThreadInitializationMode; 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_plugin_metadata; @@ -346,6 +351,152 @@ 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: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::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": 123, + "completed_at": 125, + "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 + } + }) + ); +} + +#[test] +fn command_execution_event_allows_null_thread_denormalization() { + 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: None, + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::Agent, + command_execution_family: CommandExecutionFamily::Shell, + exit_code: Some(0), + command_action_count: Some(0), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!(payload["event_params"]["thread_source"], json!(null)); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 885e93bbb9..debeefb34b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,6 +37,14 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + #[allow(dead_code)] + CommandExecution(CodexCommandExecutionEventRequest), + FileChange(CodexFileChangeEventRequest), + McpToolCall(CodexMcpToolCallEventRequest), + DynamicToolCall(CodexDynamicToolCallEventRequest), + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + WebSearch(CodexWebSearchEventRequest), + ImageGeneration(CodexImageGenerationEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -99,6 +107,237 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolKind { + Shell, + UnifiedExec, + ApplyPatch, + Mcp, + Dynamic, + Other, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolCallFinalReviewOutcome { + NotNeeded, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +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: u64, + pub(crate) completed_at: 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, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionFamily { + Shell, + UserShell, + UnifiedExec, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionSourceKind { + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CollabAgentToolKind { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[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: CommandExecutionSourceKind, + 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) collab_agent_tool: CollabAgentToolKind, + 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, From 6424152e3bbedf6a5960cfc2acd8e3dd2de8ec41 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 16:13:15 -0700 Subject: [PATCH 357/383] [codex-analytics] emit tool item events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 132 ++++ codex-rs/analytics/src/client.rs | 8 + codex-rs/analytics/src/events.rs | 20 +- codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 740 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 889 insertions(+), 26 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d9f8884c7f..4f00b36949 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3,6 +3,8 @@ 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::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -39,12 +41,18 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CommandExecutionSource; +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::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -119,6 +127,50 @@ fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) } } +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, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "item-1".to_string(), + command: "echo hi".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: Some("pid-1".to_string()), + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -600,6 +652,86 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + 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"], json!(null)); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d2d75a31ea..46c6ea2890 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -16,6 +16,7 @@ use crate::facts::TrackEventsContext; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; @@ -250,6 +251,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 debeefb34b..af43990d7e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -37,7 +37,6 @@ pub(crate) enum TrackEventRequest { ThreadInitialized(ThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), - #[allow(dead_code)] CommandExecution(CodexCommandExecutionEventRequest), FileChange(CodexFileChangeEventRequest), McpToolCall(CodexMcpToolCallEventRequest), @@ -110,21 +109,10 @@ pub(crate) struct ThreadInitializedEvent { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] -pub(crate) enum ToolKind { - Shell, - UnifiedExec, - ApplyPatch, - Mcp, - Dynamic, - Other, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -#[allow(dead_code)] -pub(crate) enum ToolCallFinalReviewOutcome { +pub(crate) enum ToolItemFinalApprovalOutcome { NotNeeded, + ConfigAllowed, + PolicyForbidden, GuardianApproved, GuardianDenied, GuardianAborted, @@ -137,7 +125,6 @@ pub(crate) enum ToolCallFinalReviewOutcome { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemTerminalStatus { Completed, Failed, @@ -148,7 +135,6 @@ pub(crate) enum ToolItemTerminalStatus { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index f1ede6a162..50312a4e08 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -90,7 +90,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 62bf19e99a..15e52e11e9 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,15 +2,37 @@ 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::CodexDynamicToolCallEventParams; +use crate::events::CodexDynamicToolCallEventRequest; +use crate::events::CodexFileChangeEventParams; +use crate::events::CodexFileChangeEventRequest; +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::CodexWebSearchEventParams; +use crate::events::CodexWebSearchEventRequest; +use crate::events::CollabAgentToolKind; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; 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_plugin_metadata; use crate::events::codex_plugin_used_metadata; @@ -27,7 +49,20 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::CollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionSource; +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::ServerNotification; +use codex_app_server_protocol::ThreadItem; +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; @@ -36,10 +71,14 @@ use codex_protocol::protocol::SkillScope; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { connections: HashMap, + threads: HashMap, + tool_items: HashMap, } struct ConnectionState { @@ -47,6 +86,18 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +#[derive(Clone, Default)] +struct ThreadMetadataState { + thread_source: Option<&'static str>, + subagent_source: Option, + parent_thread_id: Option, +} + +struct ToolItemState { + connection_id: u64, + started_at: u64, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -83,7 +134,12 @@ impl AnalyticsReducer { AnalyticsFact::ServerResponse { response: _response, } => {} - AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -137,9 +193,16 @@ impl AnalyticsReducer { input: SubAgentThreadStartedInput, out: &mut Vec, ) { - out.push(TrackEventRequest::ThreadInitialized( - subagent_thread_started_event_request(input), - )); + let event = subagent_thread_started_event_request(input); + self.threads.insert( + event.event_params.thread_id.clone(), + ThreadMetadataState { + thread_source: event.event_params.thread_source, + subagent_source: event.event_params.subagent_source.clone(), + parent_thread_id: event.event_params.parent_thread_id.clone(), + }, + ); + out.push(TrackEventRequest::ThreadInitialized(event)); } async fn ingest_skill_invoked( @@ -264,16 +327,26 @@ impl AnalyticsReducer { let Some(connection_state) = self.connections.get(&connection_id) else { return; }; + let thread_id = thread.id.clone(); + let thread_source = thread_source_name(&thread_source); + self.threads.insert( + thread_id.clone(), + ThreadMetadataState { + thread_source, + subagent_source: None, + parent_thread_id: None, + }, + ); out.push(TrackEventRequest::ThreadInitialized( ThreadInitializedEvent { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { - thread_id: thread.id, + thread_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), model, ephemeral: thread.ephemeral, - thread_source: thread_source_name(&thread_source), + thread_source, initialization_mode, subagent_source: None, parent_thread_id: None, @@ -282,6 +355,661 @@ impl AnalyticsReducer { }, )); } + + fn ingest_notification( + &mut self, + connection_id: u64, + notification: ServerNotification, + out: &mut Vec, + ) { + match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(item_id) = tool_item_id(¬ification.item) { + self.tool_items + .entry(item_id.to_string()) + .or_insert_with(|| ToolItemState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tool_item_id(¬ification.item) else { + return; + }; + let Some(started) = self.tool_items.remove(item_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + started.started_at, + completed_at, + connection_state, + self.threads.get(¬ification.thread_id), + ) { + out.push(event); + } + } + _ => {} + } + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +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, + started_at: u64, + completed_at: u64, + connection_state: &ConnectionState, + thread_metadata: Option<&ThreadMetadataState>, +) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: command_execution_source_kind(*source), + 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, + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + collab_agent_tool: collab_agent_tool_kind(tool), + 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 } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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: u64, + completed_at: u64, + connection_state: &'a ConnectionState, + thread_metadata: Option<&'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.cloned().unwrap_or_default(); + 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, + parent_thread_id: thread_metadata.parent_thread_id, + tool_name, + started_at: context.started_at, + completed_at: Some(context.completed_at), + duration_ms: outcome.duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + } +} + +fn completed_duration_ms( + item_duration_ms: Option, + started_at: u64, + completed_at: u64, +) -> Option { + item_duration_ms.or_else(|| { + completed_at + .checked_sub(started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }) +} + +fn command_execution_source_kind(source: CommandExecutionSource) -> CommandExecutionSourceKind { + match source { + CommandExecutionSource::Agent => CommandExecutionSourceKind::Agent, + CommandExecutionSource::UserShell => CommandExecutionSourceKind::UserShell, + CommandExecutionSource::UnifiedExecStartup => { + CommandExecutionSourceKind::UnifiedExecStartup + } + CommandExecutionSource::UnifiedExecInteraction => { + CommandExecutionSourceKind::UnifiedExecInteraction + } + } +} + +fn command_execution_family(source: CommandExecutionSource) -> CommandExecutionFamily { + match source { + CommandExecutionSource::Agent => CommandExecutionFamily::Shell, + CommandExecutionSource::UserShell => CommandExecutionFamily::UserShell, + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => CommandExecutionFamily::UnifiedExec, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::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", + } +} + +fn collab_agent_tool_kind(tool: &CollabAgentTool) -> CollabAgentToolKind { + match tool { + CollabAgentTool::SpawnAgent => CollabAgentToolKind::SpawnAgent, + CollabAgentTool::SendInput => CollabAgentToolKind::SendInput, + CollabAgentTool::ResumeAgent => CollabAgentToolKind::ResumeAgent, + CollabAgentTool::Wait => CollabAgentToolKind::Wait, + CollabAgentTool::CloseAgent => CollabAgentToolKind::CloseAgent, + } +} + +#[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 serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) } pub(crate) fn skill_id_for_local_skill( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From e243d74e9d4838c8e728f658178e85f1d94f8cf6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:30:30 -0700 Subject: [PATCH 358/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 93 +++++++--- codex-rs/core/src/guardian/review_session.rs | 178 ++++++++++++++++--- codex-rs/core/src/guardian/tests.rs | 8 +- 3 files changed, 221 insertions(+), 58 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 215ab2b159..60dbde6ecd 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -220,6 +224,24 @@ struct GuardianReviewMetadataFields { time_to_first_token_ms: Option, } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: false, + token_usage: metadata.token_usage, + time_to_first_token_ms: None, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -404,8 +426,8 @@ async fn run_guardian_review( let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -440,8 +462,8 @@ async fn run_guardian_review( ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -471,8 +493,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -502,8 +524,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -554,8 +576,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -748,7 +770,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -799,10 +824,12 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match session + let (session_outcome, session_metadata) = session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), @@ -817,25 +844,37 @@ pub(super) async fn run_guardian_review_session( personality: turn.personality, external_cancel, }) - .await - { + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 08825cfde2..0384e27f12 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -58,10 +60,21 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) token_usage: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -101,6 +114,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -267,10 +295,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -304,16 +336,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -321,9 +354,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -351,20 +387,25 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - run_review_on_session(trunk.as_ref(), ¶ms, deadline).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = + run_review_on_session(trunk.as_ref(), ¶ms, guardian_session_kind, deadline).await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -461,7 +502,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -480,20 +524,26 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = run_review_on_session(review_session.as_ref(), ¶ms, deadline).await; + let (outcome, _, metadata) = run_review_on_session( + review_session.as_ref(), + ¶ms, + GuardianReviewSessionKind::EphemeralForked, + deadline, + ) + .await; if let Some(review_session) = self.take_active_ephemeral(&review_session).await { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -540,8 +590,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -556,6 +611,14 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -580,6 +643,8 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -599,29 +664,45 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -650,7 +731,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -659,7 +740,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -669,7 +750,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -681,18 +762,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -700,6 +783,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -951,4 +1035,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 5e44aa972a..881e6f9e53 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -916,7 +916,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() /*external_cancel*/ None, ) .await; - let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else { + let GuardianReviewOutcome::Completed(Ok(assessment), _) = outcome else { panic!("expected guardian assessment"); }; assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); @@ -1125,13 +1125,13 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { panic!("expected third guardian assessment"); }; assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); From 8dce026390e8c2656abf3fcea9758722abb3aadf Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:33:00 -0700 Subject: [PATCH 359/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 32 +++---- codex-rs/analytics/src/client.rs | 45 ++++++--- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/analytics_events.rs | 16 ++++ .../app-server/src/bespoke_event_handling.rs | 26 +++--- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/app-server/src/command_exec.rs | 6 +- codex-rs/app-server/src/fs_watch.rs | 2 +- codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 2 + .../src/message_processor/tracing_tests.rs | 6 +- codex-rs/app-server/src/outgoing_message.rs | 92 +++++++++++++++++-- codex-rs/app-server/src/thread_status.rs | 12 +-- 15 files changed, 218 insertions(+), 70 deletions(-) create mode 100644 codex-rs/app-server/src/analytics_events.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb46674156..e7cc07b2f8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -401,7 +401,7 @@ async fn ingest_rejected_turn_steer( .await; reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(4), request: Box::new(sample_turn_steer_request( @@ -461,7 +461,7 @@ async fn ingest_turn_prerequisites( ingest_initialize(reducer, out).await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-2", /*ephemeral*/ false, "gpt-5", @@ -475,7 +475,7 @@ async fn ingest_turn_prerequisites( reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(3), request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), @@ -485,7 +485,7 @@ async fn ingest_turn_prerequisites( .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), }, @@ -837,7 +837,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -881,7 +881,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", @@ -961,7 +961,7 @@ async fn compaction_event_ingests_custom_fact() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response_with_source( "thread-1", @@ -1579,7 +1579,7 @@ async fn accepted_turn_steer_emits_expected_event() { .await; reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(4), request: Box::new(sample_turn_steer_request( @@ -1591,7 +1591,7 @@ async fn accepted_turn_steer_emits_expected_event() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), }, @@ -1733,7 +1733,7 @@ async fn turn_start_error_response_discards_pending_start_request() { ingest_initialize(&mut reducer, &mut out).await; reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(3), request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), @@ -1757,7 +1757,7 @@ async fn turn_start_error_response_discards_pending_start_request() { // failed turn/start request and attach request-scoped connection metadata. reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), }, @@ -1874,7 +1874,7 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(4), request: Box::new(sample_turn_steer_request( @@ -1886,7 +1886,7 @@ async fn accepted_steers_increment_turn_steer_count() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), }, @@ -1896,7 +1896,7 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(5), request: Box::new(sample_turn_steer_request( @@ -1920,7 +1920,7 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(6), request: Box::new(sample_turn_steer_request( @@ -1932,7 +1932,7 @@ async fn accepted_steers_increment_turn_steer_count() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), }, diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 5ba60e3ef4..6f4e78d31f 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -24,6 +24,8 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -46,8 +48,7 @@ pub(crate) struct AnalyticsEventsQueue { #[derive(Clone)] pub struct AnalyticsEventsClient { - queue: AnalyticsEventsQueue, - analytics_enabled: Option, + queue: Option, } impl AnalyticsEventsQueue { @@ -116,11 +117,15 @@ impl AnalyticsEventsClient { analytics_enabled: Option, ) -> Self { Self { - queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), - analytics_enabled, + queue: (analytics_enabled != Some(false)) + .then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url)), } } + pub fn disabled() -> Self { + Self { queue: None } + } + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, @@ -175,7 +180,7 @@ impl AnalyticsEventsClient { } pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { - self.record_fact(AnalyticsFact::Request { + self.record_fact(AnalyticsFact::ClientRequest { connection_id, request_id, request: Box::new(request), @@ -183,7 +188,10 @@ impl AnalyticsEventsClient { } pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { - if !self.queue.should_enqueue_app_used(&tracking, &app) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_app_used(&tracking, &app) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed( @@ -192,7 +200,10 @@ impl AnalyticsEventsClient { } pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { - if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_plugin_used(&tracking, &plugin) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed( @@ -255,14 +266,13 @@ impl AnalyticsEventsClient { } pub(crate) fn record_fact(&self, input: AnalyticsFact) { - if self.analytics_enabled == Some(false) { - return; + if let Some(queue) = self.queue.as_ref() { + queue.try_send(input); } - self.queue.try_send(input); } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); @@ -286,6 +296,19 @@ 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, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 5590fbfa6d..e9272babb1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -7,6 +7,8 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; @@ -268,12 +270,12 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, @@ -283,6 +285,13 @@ pub(crate) enum AnalyticsFact { error: JSONRPCErrorError, error_type: Option, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a5e5165bd2..af8da51cc8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -168,14 +168,14 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id, request_id, request, } => { self.ingest_request(connection_id, request_id, *request); } - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { @@ -192,6 +192,13 @@ impl AnalyticsReducer { AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); diff --git a/codex-rs/app-server/src/analytics_events.rs b/codex-rs/app-server/src/analytics_events.rs new file mode 100644 index 0000000000..24ed12d2ad --- /dev/null +++ b/codex-rs/app-server/src/analytics_events.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4fddee4a11..67f1f7d600 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3284,7 +3284,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3353,7 +3353,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3445,7 +3445,7 @@ mod tests { let thread_state = new_thread_state(); let thread_watch_manager = ThreadWatchManager::new(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3889,7 +3889,7 @@ mod tests { let conversation_id = ThreadId::new(); let event_turn_id = "complete1".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3953,7 +3953,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4001,7 +4001,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4043,7 +4043,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4097,7 +4097,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-123".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4185,7 +4185,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-456".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4256,7 +4256,7 @@ mod tests { let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4512,7 +4512,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4550,7 +4550,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4576,7 +4576,7 @@ mod tests { #[tokio::test] async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let conversation_id = ThreadId::new(); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f31aafeac5..eaa8c88a2e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -10369,7 +10369,7 @@ mod tests { let connection_id = ConnectionId(7); let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), vec![connection_id], diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index b72c84e906..7f76cda5c3 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -757,7 +757,7 @@ mod tests { let manager = CommandExecManager::default(); let err = manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: ConnectionRequestId { connection_id: ConnectionId(1), request_id: codex_app_server_protocol::RequestId::Integer(42), @@ -793,7 +793,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-99".to_string()), exec_request: windows_sandbox_exec_request(), @@ -843,7 +843,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index ff00051472..5255ed9eb5 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -234,7 +234,7 @@ mod tests { const OUTGOING_BUFFER: usize = 1; let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); FsWatchManager::new_with_file_watcher( - Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(OutgoingMessageSender::new_for_tests(tx)), Arc::new(FileWatcher::noop()), ) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 9ac0e6cf42..845d2e55b3 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -50,6 +50,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::analytics_events::analytics_events_client_from_config; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; @@ -357,7 +358,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -382,12 +390,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: args.environment_manager, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ca48ad0f41..25994acc9e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use crate::analytics_events::analytics_events_client_from_config; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -64,6 +65,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod analytics_events; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; @@ -646,14 +648,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 023d744502..57d587997d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -225,6 +225,7 @@ impl ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -246,6 +247,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index c1bb995bab..53c1237c3a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use crate::analytics_events::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -234,11 +235,14 @@ fn build_test_processor( mpsc::Receiver, ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..d31d608054 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: AnalyticsEventsClient, } #[derive(Clone)] @@ -203,15 +206,24 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { - pub(crate) fn new(sender: mpsc::Sender) -> Self { + pub(crate) fn new( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + #[cfg(test)] + pub(crate) fn new_for_tests(sender: mpsc::Sender) -> Self { + Self::new(sender, AnalyticsEventsClient::disabled()) + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +310,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +333,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else { + self.analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +379,10 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + { + self.analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +640,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +681,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,10 +867,53 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -876,7 +948,7 @@ mod tests { #[tokio::test] async fn send_response_clears_registered_request_context() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -901,7 +973,7 @@ mod tests { #[tokio::test] async fn send_error_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -939,7 +1011,7 @@ mod tests { #[tokio::test] async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let send_task = tokio::spawn(async move { outgoing .send_server_notification_to_connection_and_wait( @@ -983,7 +1055,7 @@ mod tests { #[tokio::test] async fn connection_closed_clears_registered_request_contexts() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let closed_connection_request = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -1017,7 +1089,7 @@ mod tests { #[tokio::test] async fn notify_client_error_forwards_error_to_waiter() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let (request_id, wait_for_result) = outgoing .send_request(ServerRequestPayload::ApplyPatchApproval( @@ -1051,7 +1123,7 @@ mod tests { #[tokio::test] async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), @@ -1108,7 +1180,7 @@ mod tests { #[tokio::test] async fn cancel_requests_for_thread_cancels_all_thread_requests() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index f78b8753a9..803616798d 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -720,9 +720,9 @@ mod tests { #[tokio::test] async fn status_change_emits_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread(test_thread( @@ -762,9 +762,9 @@ mod tests { #[tokio::test] async fn silent_upsert_skips_initial_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread_silently(test_thread( From be05015d7f7e33805a64bb8ad8a0bc231ed2c0a6 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:34:41 -0700 Subject: [PATCH 360/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 +++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 +++--- codex-rs/core/src/guardian/tests.rs | 87 +++++++++++++++++-- 6 files changed, 158 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..b3fb7f71dc 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -167,32 +167,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -382,10 +399,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f5926a0db5..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -64,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 60dbde6ecd..0bc1d08ae4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -234,7 +234,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 0384e27f12..7a043dc22d 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -72,6 +72,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -617,6 +618,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -643,6 +645,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -664,8 +667,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -675,16 +679,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 881e6f9e53..daa9453df4 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -584,11 +584,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 7ee3153b323cb9e87d3849d4347b1972f0d98d6f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:34:41 -0700 Subject: [PATCH 361/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 18 files changed, 108 insertions(+), 3 deletions(-) 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 9e94515dd9..a390d33392 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1332,6 +1332,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1406,6 +1407,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1729,6 +1731,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2235,6 +2238,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2272,6 +2276,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2324,6 +2329,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2361,6 +2367,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2535,6 +2542,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2553,6 +2561,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2564,6 +2573,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2598,6 +2608,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2654,6 +2665,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2899,6 +2911,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2958,6 +2971,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3009,6 +3023,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3057,6 +3072,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 4fddee4a11..d5195f5960 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3042,6 +3042,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index bf213a6457..3e491c654d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1381,6 +1381,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1566,6 +1567,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1591,6 +1593,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1668,6 +1671,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1686,6 +1690,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1713,6 +1718,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1759,6 +1765,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1782,6 +1789,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1805,6 +1813,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4767,6 +4776,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() turn_id, last_agent_message: None, .. + time_to_first_token_ms: None, }) if turn_id == tc.sub_id )); } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 0bc1d08ae4..e6db92b41d 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -236,7 +236,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7a043dc22d..f11e7d97aa 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -74,6 +74,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -620,6 +621,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -692,8 +694,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -737,6 +744,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -762,6 +770,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f17017316e..692a5919a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -535,11 +535,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d20fadeb4..73045ba734 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2057,6 +2057,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 09b8ce06cd..2107aef4b7 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -762,6 +762,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -793,6 +794,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1..261fd47f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1350,6 +1354,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index f3746fdbf0..2cd2790ee6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 e59928ff1c..a294e3aadf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -961,6 +961,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1311,6 +1312,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2251,6 +2253,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From 963a58a143ba6907158c55b0679dc088b612a4a1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:35:31 -0700 Subject: [PATCH 362/383] [codex-analytics] add tool item event schemas --- .../analytics/src/analytics_client_tests.rs | 153 ++++++++++++ codex-rs/analytics/src/events.rs | 228 ++++++++++++++++++ 2 files changed, 381 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e7cc07b2f8..bdb8b368e0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3,13 +3,20 @@ 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::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventRequest; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; 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_plugin_metadata; @@ -830,6 +837,152 @@ 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: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::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": 123, + "completed_at": 125, + "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 + } + }) + ); +} + +#[test] +fn command_execution_event_allows_null_thread_denormalization() { + 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: None, + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::Agent, + command_execution_family: CommandExecutionFamily::Shell, + exit_code: Some(0), + command_action_count: Some(0), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!(payload["event_params"]["thread_source"], json!(null)); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eb312da140..13c071c604 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -42,6 +42,14 @@ pub(crate) enum TrackEventRequest { Compaction(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), + #[allow(dead_code)] + CommandExecution(CodexCommandExecutionEventRequest), + FileChange(CodexFileChangeEventRequest), + McpToolCall(CodexMcpToolCallEventRequest), + DynamicToolCall(CodexDynamicToolCallEventRequest), + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + WebSearch(CodexWebSearchEventRequest), + ImageGeneration(CodexImageGenerationEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -277,6 +285,226 @@ 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 { + NotNeeded, + ConfigAllowed, + PolicyForbidden, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +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: u64, + pub(crate) completed_at: 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, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionFamily { + Shell, + UserShell, + UnifiedExec, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionSourceKind { + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CollabAgentToolKind { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[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: CommandExecutionSourceKind, + 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) collab_agent_tool: CollabAgentToolKind, + 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, From 2aba74a11fa4a04e6f283ccfd276bffeb0d99f6f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:47:56 -0700 Subject: [PATCH 363/383] [codex-analytics] emit tool item events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 172 ++++- codex-rs/analytics/src/client.rs | 11 +- codex-rs/analytics/src/events.rs | 2 - codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 705 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 875 insertions(+), 30 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bdb8b368e0..3f6e3e5338 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -58,8 +58,12 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandExecutionSource; +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; @@ -67,6 +71,7 @@ use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -290,6 +295,13 @@ fn sample_turn_completed_notification( }) } +fn notification_fact(notification: ServerNotification) -> AnalyticsFact { + AnalyticsFact::Notification { + connection_id: 7, + notification: Box::new(notification), + } +} + fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { TurnResolvedConfigFact { turn_id: turn_id.to_string(), @@ -514,9 +526,7 @@ async fn ingest_turn_prerequisites( if include_started { reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_started_notification( - "thread-2", "turn-2", - ))), + notification_fact(sample_turn_started_notification("thread-2", "turn-2")), out, ) .await; @@ -534,6 +544,50 @@ async fn ingest_turn_prerequisites( } } +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, + 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: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1196,6 +1250,86 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + 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"], json!(null)); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( @@ -1929,12 +2063,12 @@ async fn turn_start_error_response_discards_pending_start_request() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -1958,12 +2092,12 @@ async fn turn_lifecycle_emits_turn_event() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2095,12 +2229,12 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2129,12 +2263,12 @@ async fn turn_does_not_emit_without_required_prerequisites() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2154,12 +2288,12 @@ async fn turn_does_not_emit_without_required_prerequisites() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2182,12 +2316,12 @@ async fn turn_lifecycle_emits_failed_turn_event() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Failed, Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), - ))), + )), &mut out, ) .await; @@ -2214,12 +2348,12 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Interrupted, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2246,12 +2380,12 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 6f4e78d31f..a45525265b 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -293,10 +293,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, @@ -309,6 +305,13 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, connection_id: u64, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 13c071c604..13aea2cafe 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -304,7 +304,6 @@ pub(crate) enum ToolItemFinalApprovalOutcome { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemTerminalStatus { Completed, Failed, @@ -315,7 +314,6 @@ pub(crate) enum ToolItemTerminalStatus { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e9272babb1..91b9a63c83 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -292,7 +292,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index af8da51cc8..70dc2d75c1 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,14 +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::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::CollabAgentToolKind; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -17,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_plugin_metadata; @@ -47,11 +69,23 @@ use crate::now_unix_seconds; 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; +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; @@ -65,6 +99,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -73,6 +109,7 @@ pub(crate) struct AnalyticsReducer { connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, + tool_items: HashMap, } struct ConnectionState { @@ -80,6 +117,11 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolItemState { + connection_id: u64, + started_at: u64, +} + #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, @@ -88,6 +130,17 @@ struct ThreadMetadataState { parent_thread_id: Option, } +impl Default for ThreadMetadataState { + fn default() -> Self { + Self { + thread_source: None, + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + } + } +} + impl ThreadMetadataState { fn from_thread_metadata( session_source: &SessionSource, @@ -189,8 +242,11 @@ impl AnalyticsReducer { } => { self.ingest_error_response(connection_id, request_id, error_type, out); } - AnalyticsFact::Notification(notification) => { - self.ingest_notification(*notification, out); + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); } AnalyticsFact::ServerRequest { connection_id: _connection_id, @@ -596,10 +652,44 @@ impl AnalyticsReducer { fn ingest_notification( &mut self, + connection_id: u64, notification: ServerNotification, out: &mut Vec, ) { match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(item_id) = tool_item_id(¬ification.item) { + self.tool_items + .entry(item_id.to_string()) + .or_insert_with(|| ToolItemState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tool_item_id(¬ification.item) else { + return; + }; + let Some(started) = self.tool_items.remove(item_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + started.started_at, + completed_at, + connection_state, + self.thread_metadata.get(¬ification.thread_id), + ) { + out.push(event); + } + } ServerNotification::TurnStarted(notification) => { let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { connection_id: None, @@ -852,6 +942,617 @@ impl AnalyticsReducer { } } +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +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, + started_at: u64, + completed_at: u64, + connection_state: &ConnectionState, + thread_metadata: Option<&ThreadMetadataState>, +) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: command_execution_source_kind(*source), + 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, + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + collab_agent_tool: collab_agent_tool_kind(tool), + 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 } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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: u64, + completed_at: u64, + connection_state: &'a ConnectionState, + thread_metadata: Option<&'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.cloned().unwrap_or_default(); + 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, + parent_thread_id: thread_metadata.parent_thread_id, + tool_name, + started_at: context.started_at, + completed_at: Some(context.completed_at), + duration_ms: outcome.duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + } +} + +fn completed_duration_ms( + item_duration_ms: Option, + started_at: u64, + completed_at: u64, +) -> Option { + item_duration_ms.or_else(|| { + completed_at + .checked_sub(started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }) +} + +fn command_execution_source_kind(source: CommandExecutionSource) -> CommandExecutionSourceKind { + match source { + CommandExecutionSource::Agent => CommandExecutionSourceKind::Agent, + CommandExecutionSource::UserShell => CommandExecutionSourceKind::UserShell, + CommandExecutionSource::UnifiedExecStartup => { + CommandExecutionSourceKind::UnifiedExecStartup + } + CommandExecutionSource::UnifiedExecInteraction => { + CommandExecutionSourceKind::UnifiedExecInteraction + } + } +} + +fn command_execution_family(source: CommandExecutionSource) -> CommandExecutionFamily { + match source { + CommandExecutionSource::Agent => CommandExecutionFamily::Shell, + CommandExecutionSource::UserShell => CommandExecutionFamily::UserShell, + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => CommandExecutionFamily::UnifiedExec, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::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", + } +} + +fn collab_agent_tool_kind(tool: &CollabAgentTool) -> CollabAgentToolKind { + match tool { + CollabAgentTool::SpawnAgent => CollabAgentToolKind::SpawnAgent, + CollabAgentTool::SendInput => CollabAgentToolKind::SendInput, + CollabAgentTool::ResumeAgent => CollabAgentToolKind::ResumeAgent, + CollabAgentTool::Wait => CollabAgentToolKind::Wait, + CollabAgentTool::CloseAgent => CollabAgentToolKind::CloseAgent, + } +} + +#[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 serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) +} + fn codex_turn_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..c4fa538d43 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 845d3dc3b5136c5fa09cf0c70f5e8b3105e96100 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:33:00 -0700 Subject: [PATCH 364/383] [codex-analytics] ingest server requests and responses --- .../analytics/src/analytics_client_tests.rs | 32 +++---- codex-rs/analytics/src/client.rs | 45 ++++++--- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/reducer.rs | 11 ++- codex-rs/app-server/src/analytics_events.rs | 16 ++++ .../app-server/src/bespoke_event_handling.rs | 26 +++--- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/app-server/src/command_exec.rs | 6 +- codex-rs/app-server/src/fs_watch.rs | 2 +- codex-rs/app-server/src/in_process.rs | 13 ++- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 7 +- .../src/message_processor/tracing_tests.rs | 6 +- codex-rs/app-server/src/outgoing_message.rs | 92 +++++++++++++++++-- codex-rs/app-server/src/thread_status.rs | 12 +-- 15 files changed, 218 insertions(+), 75 deletions(-) create mode 100644 codex-rs/app-server/src/analytics_events.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb46674156..e7cc07b2f8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -401,7 +401,7 @@ async fn ingest_rejected_turn_steer( .await; reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(4), request: Box::new(sample_turn_steer_request( @@ -461,7 +461,7 @@ async fn ingest_turn_prerequisites( ingest_initialize(reducer, out).await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-2", /*ephemeral*/ false, "gpt-5", @@ -475,7 +475,7 @@ async fn ingest_turn_prerequisites( reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(3), request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), @@ -485,7 +485,7 @@ async fn ingest_turn_prerequisites( .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), }, @@ -837,7 +837,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_start_response( "thread-no-client", @@ -881,7 +881,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", @@ -961,7 +961,7 @@ async fn compaction_event_ingests_custom_fact() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_thread_resume_response_with_source( "thread-1", @@ -1579,7 +1579,7 @@ async fn accepted_turn_steer_emits_expected_event() { .await; reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(4), request: Box::new(sample_turn_steer_request( @@ -1591,7 +1591,7 @@ async fn accepted_turn_steer_emits_expected_event() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), }, @@ -1733,7 +1733,7 @@ async fn turn_start_error_response_discards_pending_start_request() { ingest_initialize(&mut reducer, &mut out).await; reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(3), request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), @@ -1757,7 +1757,7 @@ async fn turn_start_error_response_discards_pending_start_request() { // failed turn/start request and attach request-scoped connection metadata. reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), }, @@ -1874,7 +1874,7 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(4), request: Box::new(sample_turn_steer_request( @@ -1886,7 +1886,7 @@ async fn accepted_steers_increment_turn_steer_count() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), }, @@ -1896,7 +1896,7 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(5), request: Box::new(sample_turn_steer_request( @@ -1920,7 +1920,7 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id: 7, request_id: RequestId::Integer(6), request: Box::new(sample_turn_steer_request( @@ -1932,7 +1932,7 @@ async fn accepted_steers_increment_turn_steer_count() { .await; reducer .ingest( - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id: 7, response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), }, diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 5ba60e3ef4..6f4e78d31f 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -24,6 +24,8 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; @@ -46,8 +48,7 @@ pub(crate) struct AnalyticsEventsQueue { #[derive(Clone)] pub struct AnalyticsEventsClient { - queue: AnalyticsEventsQueue, - analytics_enabled: Option, + queue: Option, } impl AnalyticsEventsQueue { @@ -116,11 +117,15 @@ impl AnalyticsEventsClient { analytics_enabled: Option, ) -> Self { Self { - queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), - analytics_enabled, + queue: (analytics_enabled != Some(false)) + .then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url)), } } + pub fn disabled() -> Self { + Self { queue: None } + } + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, @@ -175,7 +180,7 @@ impl AnalyticsEventsClient { } pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { - self.record_fact(AnalyticsFact::Request { + self.record_fact(AnalyticsFact::ClientRequest { connection_id, request_id, request: Box::new(request), @@ -183,7 +188,10 @@ impl AnalyticsEventsClient { } pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { - if !self.queue.should_enqueue_app_used(&tracking, &app) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_app_used(&tracking, &app) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed( @@ -192,7 +200,10 @@ impl AnalyticsEventsClient { } pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { - if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) { + let Some(queue) = self.queue.as_ref() else { + return; + }; + if !queue.should_enqueue_plugin_used(&tracking, &plugin) { return; } self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed( @@ -255,14 +266,13 @@ impl AnalyticsEventsClient { } pub(crate) fn record_fact(&self, input: AnalyticsFact) { - if self.analytics_enabled == Some(false) { - return; + if let Some(queue) = self.queue.as_ref() { + queue.try_send(input); } - self.queue.try_send(input); } pub fn track_response(&self, connection_id: u64, response: ClientResponse) { - self.record_fact(AnalyticsFact::Response { + self.record_fact(AnalyticsFact::ClientResponse { connection_id, response: Box::new(response), }); @@ -286,6 +296,19 @@ 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, + request: Box::new(request), + }); + } + + pub fn track_server_response(&self, response: ServerResponse) { + self.record_fact(AnalyticsFact::ServerResponse { + response: Box::new(response), + }); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 5590fbfa6d..e9272babb1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -7,6 +7,8 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; @@ -268,12 +270,12 @@ pub(crate) enum AnalyticsFact { runtime: CodexRuntimeMetadata, rpc_transport: AppServerRpcTransport, }, - Request { + ClientRequest { connection_id: u64, request_id: RequestId, request: Box, }, - Response { + ClientResponse { connection_id: u64, response: Box, }, @@ -283,6 +285,13 @@ pub(crate) enum AnalyticsFact { error: JSONRPCErrorError, error_type: Option, }, + ServerRequest { + connection_id: u64, + request: Box, + }, + ServerResponse { + response: Box, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a5e5165bd2..af8da51cc8 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -168,14 +168,14 @@ impl AnalyticsReducer { rpc_transport, ); } - AnalyticsFact::Request { + AnalyticsFact::ClientRequest { connection_id, request_id, request, } => { self.ingest_request(connection_id, request_id, *request); } - AnalyticsFact::Response { + AnalyticsFact::ClientResponse { connection_id, response, } => { @@ -192,6 +192,13 @@ impl AnalyticsReducer { AnalyticsFact::Notification(notification) => { self.ingest_notification(*notification, out); } + AnalyticsFact::ServerRequest { + connection_id: _connection_id, + request: _request, + } => {} + AnalyticsFact::ServerResponse { + response: _response, + } => {} AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); diff --git a/codex-rs/app-server/src/analytics_events.rs b/codex-rs/app-server/src/analytics_events.rs new file mode 100644 index 0000000000..24ed12d2ad --- /dev/null +++ b/codex-rs/app-server/src/analytics_events.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use codex_analytics::AnalyticsEventsClient; +use codex_core::config::Config; +use codex_login::AuthManager; + +pub(crate) fn analytics_events_client_from_config( + auth_manager: Arc, + config: &Config, +) -> AnalyticsEventsClient { + AnalyticsEventsClient::new( + auth_manager, + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ) +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4fddee4a11..67f1f7d600 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3284,7 +3284,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3353,7 +3353,7 @@ mod tests { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3445,7 +3445,7 @@ mod tests { let thread_state = new_thread_state(); let thread_watch_manager = ThreadWatchManager::new(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3889,7 +3889,7 @@ mod tests { let conversation_id = ThreadId::new(); let event_turn_id = "complete1".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -3953,7 +3953,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4001,7 +4001,7 @@ mod tests { ) .await; let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4043,7 +4043,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4097,7 +4097,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-123".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4185,7 +4185,7 @@ mod tests { let conversation_id = ThreadId::new(); let turn_id = "turn-456".to_string(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4256,7 +4256,7 @@ mod tests { let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4512,7 +4512,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4550,7 +4550,7 @@ mod tests { #[tokio::test] async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, vec![ConnectionId(1)], @@ -4576,7 +4576,7 @@ mod tests { #[tokio::test] async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let conversation_id = ThreadId::new(); let outgoing = ThreadScopedOutgoingMessageSender::new( outgoing, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f31aafeac5..eaa8c88a2e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -10369,7 +10369,7 @@ mod tests { let connection_id = ConnectionId(7); let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), vec![connection_id], diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index b72c84e906..7f76cda5c3 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -757,7 +757,7 @@ mod tests { let manager = CommandExecManager::default(); let err = manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: ConnectionRequestId { connection_id: ConnectionId(1), request_id: codex_app_server_protocol::RequestId::Integer(42), @@ -793,7 +793,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-99".to_string()), exec_request: windows_sandbox_exec_request(), @@ -843,7 +843,7 @@ mod tests { manager .start(StartCommandExecParams { - outgoing: Arc::new(OutgoingMessageSender::new(tx)), + outgoing: Arc::new(OutgoingMessageSender::new_for_tests(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index ff00051472..5255ed9eb5 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -234,7 +234,7 @@ mod tests { const OUTGOING_BUFFER: usize = 1; let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); FsWatchManager::new_with_file_watcher( - Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(OutgoingMessageSender::new_for_tests(tx)), Arc::new(FileWatcher::noop()), ) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 9ac0e6cf42..845d2e55b3 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -50,6 +50,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::analytics_events::analytics_events_client_from_config; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; @@ -357,7 +358,14 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let auth_manager = + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -382,12 +390,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); - let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), + analytics_events_client, arg0_paths: args.arg0_paths, config: args.config, environment_manager: args.environment_manager, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ca48ad0f41..25994acc9e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use crate::analytics_events::analytics_events_client_from_config; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -64,6 +65,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; +mod analytics_events; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; @@ -646,14 +648,20 @@ pub async fn run_main_with_transport( }); let processor_handle = tokio::spawn({ - let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + analytics_events_client.clone(), + )); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, + analytics_events_client, arg0_paths, config: Arc::new(config), environment_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 023d744502..e09fa1f929 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -225,6 +225,7 @@ impl ConnectionSessionState { pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) environment_manager: Arc, @@ -246,6 +247,7 @@ impl MessageProcessor { pub(crate) fn new(args: MessageProcessorArgs) -> Self { let MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths, config, environment_manager, @@ -263,11 +265,6 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let analytics_events_client = AnalyticsEventsClient::new( - Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, - ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index c1bb995bab..53c1237c3a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -1,6 +1,7 @@ use super::ConnectionSessionState; use super::MessageProcessor; use super::MessageProcessorArgs; +use crate::analytics_events::analytics_events_client_from_config; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; @@ -234,11 +235,14 @@ fn build_test_processor( mpsc::Receiver, ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); - let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(outgoing_tx)); let auth_manager = AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + let analytics_events_client = + analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing, + analytics_events_client, arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde063..d31d608054 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -4,12 +4,14 @@ use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; @@ -117,6 +119,7 @@ pub(crate) struct OutgoingMessageSender { /// We keep them here because this is where responses, errors, and /// disconnect cleanup all get handled. request_contexts: Mutex>, + analytics_events_client: AnalyticsEventsClient, } #[derive(Clone)] @@ -203,15 +206,24 @@ impl ThreadScopedOutgoingMessageSender { } impl OutgoingMessageSender { - pub(crate) fn new(sender: mpsc::Sender) -> Self { + pub(crate) fn new( + sender: mpsc::Sender, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { Self { next_server_request_id: AtomicI64::new(0), sender, request_id_to_callback: Mutex::new(HashMap::new()), request_contexts: Mutex::new(HashMap::new()), + analytics_events_client, } } + #[cfg(test)] + pub(crate) fn new_for_tests(sender: mpsc::Sender) -> Self { + Self::new(sender, AnalyticsEventsClient::disabled()) + } + pub(crate) async fn register_request_context(&self, request_context: RequestContext) { let mut request_contexts = self.request_contexts.lock().await; if request_contexts @@ -298,7 +310,7 @@ impl OutgoingMessageSender { ); } - let outgoing_message = OutgoingMessage::Request(request); + let outgoing_message = OutgoingMessage::Request(request.clone()); let send_result = match connection_ids { None => { self.sender @@ -321,6 +333,9 @@ impl OutgoingMessageSender { { send_error = Some(err); break; + } else { + self.analytics_events_client + .track_server_request(connection_id.0, request.clone()); } } match send_error { @@ -364,6 +379,10 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { + if let Some(response) = server_response_from_result(&entry.request, result.clone()) + { + self.analytics_events_client.track_server_response(response); + } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -621,6 +640,14 @@ impl OutgoingMessageSender { } } +fn server_response_from_result(request: &ServerRequest, result: Result) -> Option { + let mut value = serde_json::to_value(request).ok()?; + let object = value.as_object_mut()?; + object.remove("params"); + object.insert("response".to_string(), result); + serde_json::from_value(value).ok() +} + /// Outgoing message from the server to the client. #[derive(Debug, Clone, Serialize)] #[serde(untagged)] @@ -654,6 +681,8 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::CommandExecutionApprovalDecision; + use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -838,10 +867,53 @@ mod tests { ); } + #[test] + fn server_response_from_result_decodes_typed_response_with_original_method() { + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + + let response = server_response_from_result( + &request, + json!({ + "decision": "acceptForSession", + }), + ) + .expect("decode typed server response"); + + let ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } = response + else { + panic!("expected command execution approval response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!( + response.decision, + CommandExecutionApprovalDecision::AcceptForSession + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -876,7 +948,7 @@ mod tests { #[tokio::test] async fn send_response_clears_registered_request_context() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(42), request_id: RequestId::Integer(7), @@ -901,7 +973,7 @@ mod tests { #[tokio::test] async fn send_error_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let request_id = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -939,7 +1011,7 @@ mod tests { #[tokio::test] async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { let (tx, mut rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let send_task = tokio::spawn(async move { outgoing .send_server_notification_to_connection_and_wait( @@ -983,7 +1055,7 @@ mod tests { #[tokio::test] async fn connection_closed_clears_registered_request_contexts() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let closed_connection_request = ConnectionRequestId { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), @@ -1017,7 +1089,7 @@ mod tests { #[tokio::test] async fn notify_client_error_forwards_error_to_waiter() { let (tx, _rx) = mpsc::channel::(4); - let outgoing = OutgoingMessageSender::new(tx); + let outgoing = OutgoingMessageSender::new_for_tests(tx); let (request_id, wait_for_result) = outgoing .send_request(ServerRequestPayload::ApplyPatchApproval( @@ -1051,7 +1123,7 @@ mod tests { #[tokio::test] async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), @@ -1108,7 +1180,7 @@ mod tests { #[tokio::test] async fn cancel_requests_for_thread_cancels_all_thread_requests() { let (tx, _rx) = mpsc::channel::(8); - let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = Arc::new(OutgoingMessageSender::new_for_tests(tx)); let thread_id = ThreadId::new(); let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing.clone(), diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index f78b8753a9..803616798d 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -720,9 +720,9 @@ mod tests { #[tokio::test] async fn status_change_emits_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread(test_thread( @@ -762,9 +762,9 @@ mod tests { #[tokio::test] async fn silent_upsert_skips_initial_notification() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(8); - let manager = ThreadWatchManager::new_with_outgoing(Arc::new(OutgoingMessageSender::new( - outgoing_tx, - ))); + let manager = ThreadWatchManager::new_with_outgoing(Arc::new( + OutgoingMessageSender::new_for_tests(outgoing_tx), + )); manager .upsert_thread_silently(test_thread( From bd6bd34a70d00c6e684b4f9c6159d2aef1f1af51 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:02:33 -0700 Subject: [PATCH 365/383] [codex-analytics] emit tool item events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 172 ++++- codex-rs/analytics/src/client.rs | 22 +- codex-rs/analytics/src/events.rs | 2 - codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 705 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 886 insertions(+), 30 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bdb8b368e0..3f6e3e5338 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -58,8 +58,12 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandExecutionSource; +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; @@ -67,6 +71,7 @@ use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -290,6 +295,13 @@ fn sample_turn_completed_notification( }) } +fn notification_fact(notification: ServerNotification) -> AnalyticsFact { + AnalyticsFact::Notification { + connection_id: 7, + notification: Box::new(notification), + } +} + fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { TurnResolvedConfigFact { turn_id: turn_id.to_string(), @@ -514,9 +526,7 @@ async fn ingest_turn_prerequisites( if include_started { reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_started_notification( - "thread-2", "turn-2", - ))), + notification_fact(sample_turn_started_notification("thread-2", "turn-2")), out, ) .await; @@ -534,6 +544,50 @@ async fn ingest_turn_prerequisites( } } +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, + 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: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1196,6 +1250,86 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + 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"], json!(null)); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( @@ -1929,12 +2063,12 @@ async fn turn_start_error_response_discards_pending_start_request() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -1958,12 +2092,12 @@ async fn turn_lifecycle_emits_turn_event() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2095,12 +2229,12 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2129,12 +2263,12 @@ async fn turn_does_not_emit_without_required_prerequisites() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2154,12 +2288,12 @@ async fn turn_does_not_emit_without_required_prerequisites() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2182,12 +2316,12 @@ async fn turn_lifecycle_emits_failed_turn_event() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Failed, Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), - ))), + )), &mut out, ) .await; @@ -2214,12 +2348,12 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Interrupted, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2246,12 +2380,12 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 6f4e78d31f..cb33dbc507 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -293,10 +293,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, @@ -309,6 +305,24 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id: 0, + notification: Box::new(notification), + }); + } + + pub fn track_connection_notification( + &self, + connection_id: u64, + notification: ServerNotification, + ) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 13c071c604..13aea2cafe 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -304,7 +304,6 @@ pub(crate) enum ToolItemFinalApprovalOutcome { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemTerminalStatus { Completed, Failed, @@ -315,7 +314,6 @@ pub(crate) enum ToolItemTerminalStatus { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e9272babb1..91b9a63c83 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -292,7 +292,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index af8da51cc8..70dc2d75c1 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,14 +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::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::CollabAgentToolKind; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -17,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_plugin_metadata; @@ -47,11 +69,23 @@ use crate::now_unix_seconds; 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; +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; @@ -65,6 +99,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -73,6 +109,7 @@ pub(crate) struct AnalyticsReducer { connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, + tool_items: HashMap, } struct ConnectionState { @@ -80,6 +117,11 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolItemState { + connection_id: u64, + started_at: u64, +} + #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, @@ -88,6 +130,17 @@ struct ThreadMetadataState { parent_thread_id: Option, } +impl Default for ThreadMetadataState { + fn default() -> Self { + Self { + thread_source: None, + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + } + } +} + impl ThreadMetadataState { fn from_thread_metadata( session_source: &SessionSource, @@ -189,8 +242,11 @@ impl AnalyticsReducer { } => { self.ingest_error_response(connection_id, request_id, error_type, out); } - AnalyticsFact::Notification(notification) => { - self.ingest_notification(*notification, out); + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); } AnalyticsFact::ServerRequest { connection_id: _connection_id, @@ -596,10 +652,44 @@ impl AnalyticsReducer { fn ingest_notification( &mut self, + connection_id: u64, notification: ServerNotification, out: &mut Vec, ) { match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(item_id) = tool_item_id(¬ification.item) { + self.tool_items + .entry(item_id.to_string()) + .or_insert_with(|| ToolItemState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tool_item_id(¬ification.item) else { + return; + }; + let Some(started) = self.tool_items.remove(item_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + started.started_at, + completed_at, + connection_state, + self.thread_metadata.get(¬ification.thread_id), + ) { + out.push(event); + } + } ServerNotification::TurnStarted(notification) => { let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { connection_id: None, @@ -852,6 +942,617 @@ impl AnalyticsReducer { } } +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +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, + started_at: u64, + completed_at: u64, + connection_state: &ConnectionState, + thread_metadata: Option<&ThreadMetadataState>, +) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: command_execution_source_kind(*source), + 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, + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + collab_agent_tool: collab_agent_tool_kind(tool), + 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 } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: completed_duration_ms(None, started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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: u64, + completed_at: u64, + connection_state: &'a ConnectionState, + thread_metadata: Option<&'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.cloned().unwrap_or_default(); + 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, + parent_thread_id: thread_metadata.parent_thread_id, + tool_name, + started_at: context.started_at, + completed_at: Some(context.completed_at), + duration_ms: outcome.duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + } +} + +fn completed_duration_ms( + item_duration_ms: Option, + started_at: u64, + completed_at: u64, +) -> Option { + item_duration_ms.or_else(|| { + completed_at + .checked_sub(started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }) +} + +fn command_execution_source_kind(source: CommandExecutionSource) -> CommandExecutionSourceKind { + match source { + CommandExecutionSource::Agent => CommandExecutionSourceKind::Agent, + CommandExecutionSource::UserShell => CommandExecutionSourceKind::UserShell, + CommandExecutionSource::UnifiedExecStartup => { + CommandExecutionSourceKind::UnifiedExecStartup + } + CommandExecutionSource::UnifiedExecInteraction => { + CommandExecutionSourceKind::UnifiedExecInteraction + } + } +} + +fn command_execution_family(source: CommandExecutionSource) -> CommandExecutionFamily { + match source { + CommandExecutionSource::Agent => CommandExecutionFamily::Shell, + CommandExecutionSource::UserShell => CommandExecutionFamily::UserShell, + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => CommandExecutionFamily::UnifiedExec, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::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", + } +} + +fn collab_agent_tool_kind(tool: &CollabAgentTool) -> CollabAgentToolKind { + match tool { + CollabAgentTool::SpawnAgent => CollabAgentToolKind::SpawnAgent, + CollabAgentTool::SendInput => CollabAgentToolKind::SendInput, + CollabAgentTool::ResumeAgent => CollabAgentToolKind::ResumeAgent, + CollabAgentTool::Wait => CollabAgentToolKind::Wait, + CollabAgentTool::CloseAgent => CollabAgentToolKind::CloseAgent, + } +} + +#[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 serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) +} + fn codex_turn_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..4469b2f013 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_connection_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_connection_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 6c8a79680dff5e14fdb98af0a5b44a6229b01268 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 17:35:31 -0700 Subject: [PATCH 366/383] [codex-analytics] add tool item event schemas --- .../analytics/src/analytics_client_tests.rs | 153 ++++++++++++ codex-rs/analytics/src/events.rs | 228 ++++++++++++++++++ 2 files changed, 381 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index e7cc07b2f8..bdb8b368e0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3,13 +3,20 @@ 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::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventRequest; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; 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_plugin_metadata; @@ -830,6 +837,152 @@ 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: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::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": 123, + "completed_at": 125, + "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 + } + }) + ); +} + +#[test] +fn command_execution_event_allows_null_thread_denormalization() { + 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: None, + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at: 123, + completed_at: Some(125), + 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: CommandExecutionSourceKind::Agent, + command_execution_family: CommandExecutionFamily::Shell, + exit_code: Some(0), + command_action_count: Some(0), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!(payload["event_params"]["thread_source"], json!(null)); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eb312da140..13c071c604 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -42,6 +42,14 @@ pub(crate) enum TrackEventRequest { Compaction(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), + #[allow(dead_code)] + CommandExecution(CodexCommandExecutionEventRequest), + FileChange(CodexFileChangeEventRequest), + McpToolCall(CodexMcpToolCallEventRequest), + DynamicToolCall(CodexDynamicToolCallEventRequest), + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + WebSearch(CodexWebSearchEventRequest), + ImageGeneration(CodexImageGenerationEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -277,6 +285,226 @@ 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 { + NotNeeded, + ConfigAllowed, + PolicyForbidden, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +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: u64, + pub(crate) completed_at: 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, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionFamily { + Shell, + UserShell, + UnifiedExec, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CommandExecutionSourceKind { + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CollabAgentToolKind { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[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: CommandExecutionSourceKind, + 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) collab_agent_tool: CollabAgentToolKind, + 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, From 53c40a342e97928761c3ddb9b9fae8b7b9fe44e5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:03:58 -0700 Subject: [PATCH 367/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 ++++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 ++++--- codex-rs/core/src/guardian/tests.rs | 83 +++++++++++++++++-- 6 files changed, 154 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..b3fb7f71dc 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -167,32 +167,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -382,10 +399,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f5926a0db5..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -64,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 60dbde6ecd..0bc1d08ae4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -234,7 +234,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 0384e27f12..7a043dc22d 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -72,6 +72,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -617,6 +618,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -643,6 +645,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -664,8 +667,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -675,16 +679,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 881e6f9e53..6efc9e6a3e 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -584,11 +584,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From aef23265a45b24486273523fe3c8bea8c17cd5c8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:09:15 -0700 Subject: [PATCH 368/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/guardian/tests.rs | 4 ++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 19 files changed, 112 insertions(+), 3 deletions(-) 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 9e94515dd9..a390d33392 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1332,6 +1332,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1406,6 +1407,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1729,6 +1731,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2235,6 +2238,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2272,6 +2276,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2324,6 +2329,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2361,6 +2367,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2535,6 +2542,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2553,6 +2561,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2564,6 +2573,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2598,6 +2608,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2654,6 +2665,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2899,6 +2911,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2958,6 +2971,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3009,6 +3023,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3057,6 +3072,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 4fddee4a11..d5195f5960 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3042,6 +3042,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index bf213a6457..06f1a45d7c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1381,6 +1381,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1566,6 +1567,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1591,6 +1593,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1668,6 +1671,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1686,6 +1690,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1713,6 +1718,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1759,6 +1765,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1782,6 +1789,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1805,6 +1813,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4766,6 +4775,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + time_to_first_token_ms: None, .. }) if turn_id == tc.sub_id )); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 0bc1d08ae4..e6db92b41d 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -236,7 +236,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7a043dc22d..f11e7d97aa 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -74,6 +74,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -620,6 +621,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -692,8 +694,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -737,6 +744,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -762,6 +770,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 6efc9e6a3e..e4ceff9f94 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -952,6 +952,10 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() assert_eq!(metadata.guardian_model, "gpt-5.4"); assert_eq!(metadata.guardian_reasoning_effort.as_deref(), Some("low")); assert!(!metadata.had_prior_review_context); + assert!( + metadata.time_to_first_token_ms.is_some(), + "guardian review metadata should capture TTFT when the nested turn completes" + ); let request = request_log.single_request(); let mut settings = Settings::clone_current(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f17017316e..692a5919a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -535,11 +535,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d20fadeb4..73045ba734 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2057,6 +2057,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 09b8ce06cd..2107aef4b7 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -762,6 +762,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -793,6 +794,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1..261fd47f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1350,6 +1354,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index f3746fdbf0..2cd2790ee6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 e59928ff1c..a294e3aadf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -961,6 +961,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1311,6 +1312,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2251,6 +2253,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From 9f94b8284132782deb6775ecd6f8861e0fd41790 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:32:50 -0700 Subject: [PATCH 369/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/guardian/mod.rs | 11 +- codex-rs/core/src/guardian/review.rs | 457 ++++++++++++++++++++++++++- 3 files changed, 448 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9cd58e044f..408b56570a 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -40,7 +41,7 @@ use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; +use crate::guardian::review_approval_request_with_source_and_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -747,12 +748,13 @@ fn spawn_guardian_review( let _ = tx.send(ReviewDecision::Denied); return; }; - let decision = runtime.block_on(review_approval_request_with_cancel( + let decision = runtime.block_on(review_approval_request_with_source_and_cancel( &session, &turn, review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 67e9a828ee..f5926a0db5 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -19,6 +19,7 @@ mod review_session; use std::time::Duration; use codex_protocol::protocol::GuardianAssessmentDecisionSource; +use codex_protocol::protocol::GuardianAssessmentOutcome; use serde::Deserialize; use serde::Serialize; @@ -30,7 +31,9 @@ pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; +pub(crate) use review::review_approval_request_with_source_and_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review_session::GuardianReviewSessionManager; @@ -45,14 +48,6 @@ const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const TRUNCATION_TAG: &str = "truncated"; -/// Final allow/deny outcome returned by the guardian reviewer. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum GuardianAssessmentOutcome { - Allow, - Deny, -} - /// Structured output contract that the guardian reviewer must satisfy. #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct GuardianAssessment { diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 18107f7377..cf93aedecb 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,14 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -10,12 +19,14 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -76,10 +87,34 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { Completed(anyhow::Result), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -89,6 +124,166 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome, + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +311,24 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +350,27 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at: now_unix_seconds(), + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +396,138 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, )) .await; + let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(Ok(assessment)) => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if matches!(assessment.outcome, GuardianAssessmentOutcome::Allow) { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if matches!( + assessment.outcome, + GuardianAssessmentOutcome::Allow + ) { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err)) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { + let metadata = GuardianReviewMetadataFields::default(); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +555,28 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -311,11 +676,34 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, )) .await } +pub(crate) async fn review_approval_request_with_source_and_cancel( + session: &Arc, + turn: &Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + cancel_token: CancellationToken, +) -> ReviewDecision { + run_guardian_review( + Arc::clone(session), + Arc::clone(turn), + review_id, + request, + retry_reason, + approval_request_source, + Some(cancel_token), + ) + .await +} + +#[cfg(test)] pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, @@ -330,6 +718,7 @@ pub(crate) async fn review_approval_request_with_cancel( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, Some(cancel_token), )) .await @@ -360,7 +749,9 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + } }, None => None, }; @@ -410,7 +801,7 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), }; match Box::pin( @@ -432,15 +823,49 @@ pub(super) async fn run_guardian_review_session( ) .await { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), + Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + } + } + None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) } GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, } } + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); + } +} From 4fc3fcaf0dc8cb9753a1379ce5687d752dc3b495 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:32:50 -0700 Subject: [PATCH 370/383] [codex-analytics] guardian review analytics schema polishing --- .../analytics/src/analytics_client_tests.rs | 136 ++++++++++++++++++ codex-rs/analytics/src/events.rs | 83 +++-------- codex-rs/analytics/src/lib.rs | 4 - codex-rs/protocol/src/approvals.rs | 8 ++ codex-rs/protocol/src/protocol.rs | 1 + 5 files changed, 167 insertions(+), 65 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb46674156..ea5893c60e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -8,6 +8,12 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexTurnEventRequest; +use crate::events::GuardianApprovalRequestSource; +use crate::events::GuardianReviewDecision; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewFailureReason; +use crate::events::GuardianReviewTerminalStatus; +use crate::events::GuardianReviewedAction; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; @@ -78,6 +84,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; @@ -1043,6 +1050,135 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + 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: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-guardian", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(Box::new( + GuardianReviewEventParams { + thread_id: "thread-guardian".to_string(), + turn_id: "turn-guardian".to_string(), + review_id: "review-guardian".to_string(), + target_item_id: None, + approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent, + reviewed_action: GuardianReviewedAction::NetworkAccess { + protocol: NetworkApprovalProtocol::Https, + port: 443, + }, + reviewed_action_truncated: false, + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + review_timeout_ms: 90_000, + tool_call_count: None, + time_to_first_token_ms: None, + completion_latency_ms: Some(90_000), + started_at: 100, + completed_at: Some(190), + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: 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_guardian_review"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); + assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); + assert_eq!(payload[0]["event_params"]["target_item_id"], json!(null)); + assert_eq!( + payload[0]["event_params"]["approval_request_source"], + "delegated_subagent" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.1.0" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["type"], + "network_access" + ); + assert_eq!( + payload[0]["event_params"]["reviewed_action"]["protocol"], + "https" + ); + assert_eq!(payload[0]["event_params"]["reviewed_action"]["port"], 443); + assert!(payload[0]["event_params"].get("retry_reason").is_none()); + assert!(payload[0]["event_params"].get("rationale").is_none()); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("target") + .is_none() + ); + assert!( + payload[0]["event_params"]["reviewed_action"] + .get("host") + .is_none() + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out"); + assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout"); + assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eb312da140..ea232df071 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,11 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CompactionImplementation; +use crate::facts::CompactionPhase; +use crate::facts::CompactionReason; +use crate::facts::CompactionStatus; +use crate::facts::CompactionStrategy; +use crate::facts::CompactionTrigger; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -15,6 +21,10 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianCommandSource; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -146,31 +156,6 @@ pub enum GuardianReviewSessionKind { EphemeralForked, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewRiskLevel { - Low, - Medium, - High, - Critical, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GuardianReviewOutcome { - Allow, - Deny, -} - #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum GuardianApprovalRequestSource { @@ -185,36 +170,21 @@ pub enum GuardianApprovalRequestSource { #[serde(tag = "type", rename_all = "snake_case")] pub enum GuardianReviewedAction { Shell { - command: Vec, - command_display: String, - cwd: String, sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, }, UnifiedExec { - command: Vec, - command_display: String, - cwd: String, sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, tty: bool, }, Execve { source: GuardianCommandSource, program: String, - argv: Vec, - cwd: String, additional_permissions: Option, }, - ApplyPatch { - cwd: String, - files: Vec, - }, + ApplyPatch {}, NetworkAccess { - target: String, - host: String, protocol: NetworkApprovalProtocol, port: u16, }, @@ -227,37 +197,28 @@ pub enum GuardianReviewedAction { }, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GuardianCommandSource { - Shell, - UnifiedExec, -} - #[derive(Clone, Serialize)] pub struct GuardianReviewEventParams { pub thread_id: String, pub turn_id: String, pub review_id: String, - pub target_item_id: String, - pub retry_reason: Option, + pub target_item_id: Option, pub approval_request_source: GuardianApprovalRequestSource, pub reviewed_action: GuardianReviewedAction, pub reviewed_action_truncated: bool, pub decision: GuardianReviewDecision, pub terminal_status: GuardianReviewTerminalStatus, pub failure_reason: Option, - pub risk_level: Option, - pub user_authorization: Option, - pub outcome: Option, - pub rationale: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, pub guardian_thread_id: Option, pub guardian_session_kind: Option, pub guardian_model: Option, pub guardian_reasoning_effort: Option, pub had_prior_review_context: Option, pub review_timeout_ms: u64, - pub tool_call_count: u64, + pub tool_call_count: Option, pub time_to_first_token_ms: Option, pub completion_latency_ms: Option, pub started_at: u64, @@ -309,12 +270,12 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) thread_source: Option<&'static str>, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, - pub(crate) trigger: crate::facts::CompactionTrigger, - pub(crate) reason: crate::facts::CompactionReason, - pub(crate) implementation: crate::facts::CompactionImplementation, - pub(crate) phase: crate::facts::CompactionPhase, - pub(crate) strategy: crate::facts::CompactionStrategy, - pub(crate) status: crate::facts::CompactionStatus, + pub(crate) trigger: CompactionTrigger, + pub(crate) reason: CompactionReason, + pub(crate) implementation: CompactionImplementation, + pub(crate) phase: CompactionPhase, + pub(crate) strategy: CompactionStrategy, + pub(crate) status: CompactionStatus, pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1a1a123152..a71fe8574f 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,15 +9,11 @@ use std::time::UNIX_EPOCH; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; -pub use events::GuardianCommandSource; pub use events::GuardianReviewDecision; pub use events::GuardianReviewEventParams; pub use events::GuardianReviewFailureReason; -pub use events::GuardianReviewOutcome; -pub use events::GuardianReviewRiskLevel; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; -pub use events::GuardianReviewUserAuthorization; pub use events::GuardianReviewedAction; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index db00db9b32..b8839b51b6 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -100,6 +100,14 @@ pub enum GuardianUserAuthorization { High, } +/// Final allow/deny outcome returned by the guardian reviewer. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum GuardianAssessmentOutcome { + Allow, + Deny, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum GuardianAssessmentStatus { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 244f748c82..7d20fadeb4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -67,6 +67,7 @@ pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::GuardianAssessmentAction; pub use crate::approvals::GuardianAssessmentDecisionSource; pub use crate::approvals::GuardianAssessmentEvent; +pub use crate::approvals::GuardianAssessmentOutcome; pub use crate::approvals::GuardianAssessmentStatus; pub use crate::approvals::GuardianCommandSource; pub use crate::approvals::GuardianRiskLevel; From 86275030013ee1b62dcecb5ea04bb0a8339e05c4 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:36:48 -0700 Subject: [PATCH 371/383] [codex-analytics] emit tool item events from item lifecycle --- .../analytics/src/analytics_client_tests.rs | 172 ++++- codex-rs/analytics/src/client.rs | 22 +- codex-rs/analytics/src/events.rs | 2 - codex-rs/analytics/src/facts.rs | 5 +- codex-rs/analytics/src/reducer.rs | 709 +++++++++++++++++- codex-rs/app-server/src/outgoing_message.rs | 10 +- 6 files changed, 890 insertions(+), 30 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bdb8b368e0..3f6e3e5338 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -58,8 +58,12 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandExecutionSource; +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; @@ -67,6 +71,7 @@ use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +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; @@ -290,6 +295,13 @@ fn sample_turn_completed_notification( }) } +fn notification_fact(notification: ServerNotification) -> AnalyticsFact { + AnalyticsFact::Notification { + connection_id: 7, + notification: Box::new(notification), + } +} + fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { TurnResolvedConfigFact { turn_id: turn_id.to_string(), @@ -514,9 +526,7 @@ async fn ingest_turn_prerequisites( if include_started { reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_started_notification( - "thread-2", "turn-2", - ))), + notification_fact(sample_turn_started_notification("thread-2", "turn-2")), out, ) .await; @@ -534,6 +544,50 @@ async fn ingest_turn_prerequisites( } } +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, + 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: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -1196,6 +1250,86 @@ async fn compaction_event_ingests_custom_fact() { assert_eq!(payload[0]["event_params"]["status"], "failed"); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), &mut events) + .await; + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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, + /*duration_ms*/ None, + ), + })), + }, + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification { + connection_id: 7, + 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(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"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + 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"], json!(null)); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( @@ -1929,12 +2063,12 @@ async fn turn_start_error_response_discards_pending_start_request() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -1958,12 +2092,12 @@ async fn turn_lifecycle_emits_turn_event() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2095,12 +2229,12 @@ async fn accepted_steers_increment_turn_steer_count() { reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2129,12 +2263,12 @@ async fn turn_does_not_emit_without_required_prerequisites() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2154,12 +2288,12 @@ async fn turn_does_not_emit_without_required_prerequisites() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2182,12 +2316,12 @@ async fn turn_lifecycle_emits_failed_turn_event() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Failed, Some(codex_app_server_protocol::CodexErrorInfo::BadRequest), - ))), + )), &mut out, ) .await; @@ -2214,12 +2348,12 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Interrupted, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; @@ -2246,12 +2380,12 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { .await; reducer .ingest( - AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + notification_fact(sample_turn_completed_notification( "thread-2", "turn-2", AppServerTurnStatus::Completed, /*codex_error_info*/ None, - ))), + )), &mut out, ) .await; diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 6f4e78d31f..cb33dbc507 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -293,10 +293,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, @@ -309,6 +305,24 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + self.record_fact(AnalyticsFact::Notification { + connection_id: 0, + notification: Box::new(notification), + }); + } + + pub fn track_connection_notification( + &self, + connection_id: u64, + notification: ServerNotification, + ) { + self.record_fact(AnalyticsFact::Notification { + connection_id, + 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 13c071c604..13aea2cafe 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -304,7 +304,6 @@ pub(crate) enum ToolItemFinalApprovalOutcome { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemTerminalStatus { Completed, Failed, @@ -315,7 +314,6 @@ pub(crate) enum ToolItemTerminalStatus { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -#[allow(dead_code)] pub(crate) enum ToolItemFailureKind { ToolError, ApprovalDenied, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e9272babb1..91b9a63c83 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -292,7 +292,10 @@ pub(crate) enum AnalyticsFact { ServerResponse { response: Box, }, - Notification(Box), + Notification { + connection_id: u64, + notification: Box, + }, // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. Custom(CustomAnalyticsFact), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index af8da51cc8..3c10f8df52 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,14 +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::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::CollabAgentToolKind; +use crate::events::CommandExecutionFamily; +use crate::events::CommandExecutionSourceKind; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -17,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_plugin_metadata; @@ -47,11 +69,23 @@ use crate::now_unix_seconds; 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; +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; @@ -65,6 +99,8 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -73,6 +109,7 @@ pub(crate) struct AnalyticsReducer { connections: HashMap, thread_connections: HashMap, thread_metadata: HashMap, + tool_items: HashMap, } struct ConnectionState { @@ -80,6 +117,11 @@ struct ConnectionState { runtime: CodexRuntimeMetadata, } +struct ToolItemState { + connection_id: u64, + started_at: u64, +} + #[derive(Clone)] struct ThreadMetadataState { thread_source: Option<&'static str>, @@ -88,6 +130,17 @@ struct ThreadMetadataState { parent_thread_id: Option, } +impl Default for ThreadMetadataState { + fn default() -> Self { + Self { + thread_source: None, + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + } + } +} + impl ThreadMetadataState { fn from_thread_metadata( session_source: &SessionSource, @@ -189,8 +242,11 @@ impl AnalyticsReducer { } => { self.ingest_error_response(connection_id, request_id, error_type, out); } - AnalyticsFact::Notification(notification) => { - self.ingest_notification(*notification, out); + AnalyticsFact::Notification { + connection_id, + notification, + } => { + self.ingest_notification(connection_id, *notification, out); } AnalyticsFact::ServerRequest { connection_id: _connection_id, @@ -596,10 +652,44 @@ impl AnalyticsReducer { fn ingest_notification( &mut self, + connection_id: u64, notification: ServerNotification, out: &mut Vec, ) { match notification { + ServerNotification::ItemStarted(notification) => { + if let Some(item_id) = tool_item_id(¬ification.item) { + self.tool_items + .entry(item_id.to_string()) + .or_insert_with(|| ToolItemState { + connection_id, + started_at: now_unix_secs(), + }); + } + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tool_item_id(¬ification.item) else { + return; + }; + let Some(started) = self.tool_items.remove(item_id) else { + return; + }; + let Some(connection_state) = self.connections.get(&started.connection_id) else { + return; + }; + let completed_at = now_unix_secs(); + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + started.started_at, + completed_at, + connection_state, + self.thread_metadata.get(¬ification.thread_id), + ) { + out.push(event); + } + } ServerNotification::TurnStarted(notification) => { let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { connection_id: None, @@ -852,6 +942,621 @@ impl AnalyticsReducer { } } +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +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, + started_at: u64, + completed_at: u64, + connection_state: &ConnectionState, + thread_metadata: Option<&ThreadMetadataState>, +) -> Option { + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: command_execution_source_kind(*source), + 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, + } => { + 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: observed_completed_duration_ms(started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + duration_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: completed_duration_ms( + option_i64_to_u64(*duration_ms), + started_at, + completed_at, + ), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: observed_completed_duration_ms(started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + connection_state, + thread_metadata, + }, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + collab_agent_tool: collab_agent_tool_kind(tool), + 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 } => { + 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: observed_completed_duration_ms(started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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, + .. + } => { + 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: observed_completed_duration_ms(started_at, completed_at), + }, + ToolItemContext { + started_at, + completed_at, + 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: u64, + completed_at: u64, + connection_state: &'a ConnectionState, + thread_metadata: Option<&'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.cloned().unwrap_or_default(); + 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, + parent_thread_id: thread_metadata.parent_thread_id, + tool_name, + started_at: context.started_at, + completed_at: Some(context.completed_at), + duration_ms: outcome.duration_ms, + execution_started: true, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + retry_count: 0, + } +} + +fn completed_duration_ms( + item_duration_ms: Option, + started_at: u64, + completed_at: u64, +) -> Option { + item_duration_ms.or_else(|| { + completed_at + .checked_sub(started_at) + .map(|duration_secs| duration_secs.saturating_mul(1000)) + }) +} + +fn observed_completed_duration_ms(started_at: u64, completed_at: u64) -> Option { + completed_duration_ms(/*item_duration_ms*/ None, started_at, completed_at) +} + +fn command_execution_source_kind(source: CommandExecutionSource) -> CommandExecutionSourceKind { + match source { + CommandExecutionSource::Agent => CommandExecutionSourceKind::Agent, + CommandExecutionSource::UserShell => CommandExecutionSourceKind::UserShell, + CommandExecutionSource::UnifiedExecStartup => { + CommandExecutionSourceKind::UnifiedExecStartup + } + CommandExecutionSource::UnifiedExecInteraction => { + CommandExecutionSourceKind::UnifiedExecInteraction + } + } +} + +fn command_execution_family(source: CommandExecutionSource) -> CommandExecutionFamily { + match source { + CommandExecutionSource::Agent => CommandExecutionFamily::Shell, + CommandExecutionSource::UserShell => CommandExecutionFamily::UserShell, + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => CommandExecutionFamily::UnifiedExec, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::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", + } +} + +fn collab_agent_tool_kind(tool: &CollabAgentTool) -> CollabAgentToolKind { + match tool { + CollabAgentTool::SpawnAgent => CollabAgentToolKind::SpawnAgent, + CollabAgentTool::SendInput => CollabAgentToolKind::SendInput, + CollabAgentTool::ResumeAgent => CollabAgentToolKind::ResumeAgent, + CollabAgentTool::Wait => CollabAgentToolKind::Wait, + CollabAgentTool::CloseAgent => CollabAgentToolKind::CloseAgent, + } +} + +#[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 serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) +} + fn codex_turn_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d31d608054..4469b2f013 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -537,7 +537,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 @@ -561,6 +561,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_connection_notification(connection_id.0, notification.clone()); } } } @@ -571,7 +574,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 @@ -583,6 +586,9 @@ impl OutgoingMessageSender { .await { warn!("failed to send server notification to client: {err:?}"); + } else { + self.analytics_events_client + .track_connection_notification(connection_id.0, notification); } let _ = write_complete_rx.await; } From 8c7403a86f83081743630519ff0e6623e30243fd Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:36:58 -0700 Subject: [PATCH 372/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 123 ++++++++----- codex-rs/core/src/guardian/review_session.rs | 178 ++++++++++++++++--- codex-rs/core/src/guardian/tests.rs | 8 +- 3 files changed, 235 insertions(+), 74 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index cf93aedecb..865d05d784 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -220,6 +224,24 @@ struct GuardianReviewMetadataFields { time_to_first_token_ms: Option, } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: false, + token_usage: metadata.token_usage, + time_to_first_token_ms: None, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -404,8 +426,8 @@ async fn run_guardian_review( let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -440,8 +462,8 @@ async fn run_guardian_review( ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -471,8 +493,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -502,8 +524,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -554,8 +576,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { - let metadata = GuardianReviewMetadataFields::default(); + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -750,7 +772,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -801,45 +826,57 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match Box::pin( - session - .guardian_review_session - .run_review(GuardianReviewSessionParams { - parent_session: Arc::clone(&session), - parent_turn: turn.clone(), - spawn_config: guardian_config, - request, - retry_reason, - schema, - model: guardian_model, - reasoning_effort: guardian_reasoning_effort, - reasoning_summary: turn.reasoning_summary, - personality: turn.personality, - external_cancel, - }), - ) - .await - { + let (session_outcome, session_metadata) = Box::pin(session.guardian_review_session.run_review( + GuardianReviewSessionParams { + parent_session: Arc::clone(&session), + parent_turn: turn.clone(), + spawn_config: guardian_config, + request, + retry_reason, + schema, + model: guardian_model, + reasoning_effort: guardian_reasoning_effort, + reasoning_summary: turn.reasoning_summary, + personality: turn.personality, + external_cancel, + }, + )) + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 239754c508..7aaae56e46 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -58,10 +60,21 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) token_usage: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -101,6 +114,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -267,10 +295,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -304,16 +336,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -321,9 +354,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -349,20 +385,30 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - Box::pin(run_review_on_session(trunk.as_ref(), ¶ms, deadline)).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = Box::pin(run_review_on_session( + trunk.as_ref(), + ¶ms, + guardian_session_kind, + deadline, + )) + .await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -459,7 +505,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -478,17 +527,18 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = Box::pin(run_review_on_session( + let (outcome, _, metadata) = Box::pin(run_review_on_session( review_session.as_ref(), ¶ms, + GuardianReviewSessionKind::EphemeralForked, deadline, )) .await; @@ -496,7 +546,7 @@ impl GuardianReviewSessionManager { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -543,8 +593,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -559,6 +614,14 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -583,6 +646,8 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -602,29 +667,45 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -653,7 +734,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -662,7 +743,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -672,7 +753,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -684,18 +765,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -703,6 +786,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -954,4 +1038,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8940753d27..96444ab8db 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -916,7 +916,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() /*external_cancel*/ None, ) .await; - let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else { + let GuardianReviewOutcome::Completed(Ok(assessment), _) = outcome else { panic!("expected guardian assessment"); }; assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); @@ -1125,13 +1125,13 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { panic!("expected third guardian assessment"); }; assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); From eb2499434fbdcc75acd28bfabd0e707617b52c16 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:36:58 -0700 Subject: [PATCH 373/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 ++++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 ++++--- codex-rs/core/src/guardian/tests.rs | 83 +++++++++++++++++-- 6 files changed, 154 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..b3fb7f71dc 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -167,32 +167,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -382,10 +399,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f5926a0db5..ddd17439ce 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -64,7 +64,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 865d05d784..0e6d1d3e9e 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -234,7 +234,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7aaae56e46..80ccb75cb0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -72,6 +72,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -620,6 +621,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -646,6 +648,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -667,8 +670,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -678,16 +682,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 96444ab8db..726f61f634 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -584,11 +584,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 3bbdaaafc4aa0a0fda2af8b13dddf44a165498d5 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 14 Apr 2026 18:36:58 -0700 Subject: [PATCH 374/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/guardian/tests.rs | 4 ++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 19 files changed, 112 insertions(+), 3 deletions(-) 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 9e94515dd9..a390d33392 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1332,6 +1332,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1406,6 +1407,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1729,6 +1731,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2235,6 +2238,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2272,6 +2276,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2324,6 +2329,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2361,6 +2367,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2535,6 +2542,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2553,6 +2561,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2564,6 +2573,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2598,6 +2608,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2654,6 +2665,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2899,6 +2911,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2958,6 +2971,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3009,6 +3023,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3057,6 +3072,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 4fddee4a11..d5195f5960 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3042,6 +3042,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6de8909e6d..6e4e870e48 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1381,6 +1381,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1566,6 +1567,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1591,6 +1593,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1668,6 +1671,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1686,6 +1690,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1713,6 +1718,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1759,6 +1765,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1782,6 +1789,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1805,6 +1813,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4785,6 +4794,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + time_to_first_token_ms: None, .. }) if turn_id == tc.sub_id )); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 0e6d1d3e9e..618dc38e7a 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -236,7 +236,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 80ccb75cb0..9dbcf18ef0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -74,6 +74,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -623,6 +624,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -695,8 +697,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -740,6 +747,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -765,6 +773,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 726f61f634..cceae42e9f 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -952,6 +952,10 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() assert_eq!(metadata.guardian_model, "gpt-5.4"); assert_eq!(metadata.guardian_reasoning_effort.as_deref(), Some("low")); assert!(!metadata.had_prior_review_context); + assert!( + metadata.time_to_first_token_ms.is_some(), + "guardian review metadata should capture TTFT when the nested turn completes" + ); let request = request_log.single_request(); let mut settings = Settings::clone_current(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f17017316e..692a5919a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -535,11 +535,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d20fadeb4..73045ba734 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2057,6 +2057,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 09b8ce06cd..2107aef4b7 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -762,6 +762,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -793,6 +794,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1..261fd47f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1350,6 +1354,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index f3746fdbf0..2cd2790ee6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 e59928ff1c..a294e3aadf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -961,6 +961,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1311,6 +1312,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2251,6 +2253,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From ed2f2344ea40bb3639f18816fef683ff34f3c4f0 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 14:31:15 -0700 Subject: [PATCH 375/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 6 +- codex-rs/core/src/guardian/mod.rs | 2 + codex-rs/core/src/guardian/review.rs | 470 ++++++++++++++++++++++++++- 3 files changed, 460 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9cd58e044f..408b56570a 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -40,7 +41,7 @@ use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; +use crate::guardian::review_approval_request_with_source_and_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -747,12 +748,13 @@ fn spawn_guardian_review( let _ = tx.send(ReviewDecision::Denied); return; }; - let decision = runtime.block_on(review_approval_request_with_cancel( + let decision = runtime.block_on(review_approval_request_with_source_and_cancel( &session, &turn, review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 67e9a828ee..f9647725fd 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -30,7 +30,9 @@ pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; +pub(crate) use review::review_approval_request_with_source_and_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review_session::GuardianReviewSessionManager; diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 18107f7377..a3fd35c6a7 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,14 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -10,12 +19,14 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -76,10 +87,34 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { Completed(anyhow::Result), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -89,6 +124,179 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +fn analytics_guardian_assessment_outcome( + outcome: GuardianAssessmentOutcome, +) -> codex_protocol::protocol::GuardianAssessmentOutcome { + match outcome { + GuardianAssessmentOutcome::Allow => { + codex_protocol::protocol::GuardianAssessmentOutcome::Allow + } + GuardianAssessmentOutcome::Deny => { + codex_protocol::protocol::GuardianAssessmentOutcome::Deny + } + } +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome.map(analytics_guardian_assessment_outcome), + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +324,24 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +363,27 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at: now_unix_seconds(), + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +409,138 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, )) .await; + let completed_at = now_unix_seconds(); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(Ok(assessment)) => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if matches!(assessment.outcome, GuardianAssessmentOutcome::Allow) { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if matches!( + assessment.outcome, + GuardianAssessmentOutcome::Allow + ) { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err)) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure) => { + let metadata = GuardianReviewMetadataFields::default(); + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { + let metadata = GuardianReviewMetadataFields::default(); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +568,28 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + let metadata = GuardianReviewMetadataFields::default(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + }, + ); session .send_event( turn.as_ref(), @@ -311,11 +689,34 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, )) .await } +pub(crate) async fn review_approval_request_with_source_and_cancel( + session: &Arc, + turn: &Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, + cancel_token: CancellationToken, +) -> ReviewDecision { + run_guardian_review( + Arc::clone(session), + Arc::clone(turn), + review_id, + request, + retry_reason, + approval_request_source, + Some(cancel_token), + ) + .await +} + +#[cfg(test)] pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, @@ -330,6 +731,7 @@ pub(crate) async fn review_approval_request_with_cancel( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, Some(cancel_token), )) .await @@ -360,7 +762,9 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + } }, None => None, }; @@ -410,7 +814,7 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), }; match Box::pin( @@ -432,15 +836,49 @@ pub(super) async fn run_guardian_review_session( ) .await { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), + Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + } + } + None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) } GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, } } + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); + } +} From 2fd06c03716156edfba4bd9f2f2c54cbe699686f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 376/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 2 + codex-rs/core/src/guardian/review.rs | 414 +++++++++++++++++++++++++-- codex-rs/core/src/guardian/tests.rs | 1 + 3 files changed, 399 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9cd58e044f..db18081785 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -753,6 +754,7 @@ fn spawn_guardian_review( review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 18107f7377..ba09ad1af4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,14 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -10,12 +19,14 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -76,10 +87,34 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { Completed(anyhow::Result), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -89,6 +124,201 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsTerminal { + fn from_metadata(metadata: GuardianReviewMetadataFields, completed_at: u64) -> Self { + Self { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: None, + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + } + } +} + +fn analytics_guardian_assessment_outcome( + outcome: GuardianAssessmentOutcome, +) -> codex_protocol::protocol::GuardianAssessmentOutcome { + match outcome { + GuardianAssessmentOutcome::Allow => { + codex_protocol::protocol::GuardianAssessmentOutcome::Allow + } + GuardianAssessmentOutcome::Deny => { + codex_protocol::protocol::GuardianAssessmentOutcome::Deny + } + } +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome.map(analytics_guardian_assessment_outcome), + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +346,24 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +385,19 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + ..GuardianReviewAnalyticsTerminal::from_metadata( + GuardianReviewMetadataFields::default(), + now_unix_seconds(), + ) + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +423,97 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, )) .await; + let completed_at = now_unix_seconds(); + let terminal = || { + GuardianReviewAnalyticsTerminal::from_metadata( + GuardianReviewMetadataFields::default(), + completed_at, + ) + }; let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(Ok(assessment)) => { + let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if approved { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if approved { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + ..terminal() + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err)) => { + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + ..terminal() + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure) => { + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + ..terminal() + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + ..terminal() + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +541,16 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + ..terminal() + }, + ); session .send_event( turn.as_ref(), @@ -311,6 +650,7 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, )) .await @@ -322,16 +662,18 @@ pub(crate) async fn review_approval_request_with_cancel( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, cancel_token: CancellationToken, ) -> ReviewDecision { - Box::pin(run_guardian_review( + run_guardian_review( Arc::clone(session), Arc::clone(turn), review_id, request, retry_reason, + approval_request_source, Some(cancel_token), - )) + ) .await } @@ -360,7 +702,9 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + } }, None => None, }; @@ -410,7 +754,7 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), }; match Box::pin( @@ -432,15 +776,49 @@ pub(super) async fn run_guardian_review_session( ) .await { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), + Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + } + } + None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) } GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, } } + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); + } +} diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8940753d27..82d4127a2b 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -701,6 +701,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { .to_string(), }, /*retry_reason*/ None, + GuardianApprovalRequestSource::MainTurn, cancel_token, ) .await; From 882a3710847d2119aa9538d749064af8e38ffd2c Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 377/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 136 +++++++++----- codex-rs/core/src/guardian/review_session.rs | 178 ++++++++++++++++--- codex-rs/core/src/guardian/tests.rs | 8 +- 3 files changed, 242 insertions(+), 80 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index ba09ad1af4..911b3839e0 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -255,6 +259,24 @@ fn analytics_guardian_assessment_outcome( } } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: false, + token_usage: metadata.token_usage, + time_to_first_token_ms: None, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -430,15 +452,12 @@ async fn run_guardian_review( .await; let completed_at = now_unix_seconds(); - let terminal = || { - GuardianReviewAnalyticsTerminal::from_metadata( - GuardianReviewMetadataFields::default(), - completed_at, - ) - }; + let terminal = + |metadata| GuardianReviewAnalyticsTerminal::from_metadata(metadata, completed_at); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -457,12 +476,13 @@ async fn run_guardian_review( risk_level: Some(assessment.risk_level), user_authorization: Some(assessment.user_authorization), outcome: Some(assessment.outcome), - ..terminal() + ..terminal(metadata) }, ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -471,7 +491,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::FailedClosed, failure_reason: Some(GuardianReviewFailureReason::SessionError), - ..terminal() + ..terminal(metadata) }, ); GuardianAssessment { @@ -481,7 +501,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -490,7 +511,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::FailedClosed, failure_reason: Some(failure.reason()), - ..terminal() + ..terminal(metadata) }, ); GuardianAssessment { @@ -500,7 +521,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -511,7 +533,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::TimedOut, failure_reason: Some(GuardianReviewFailureReason::Timeout), - ..terminal() + ..terminal(metadata) }, ); session @@ -540,7 +562,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -548,7 +571,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Aborted, terminal_status: GuardianReviewTerminalStatus::Aborted, failure_reason: Some(GuardianReviewFailureReason::Cancelled), - ..terminal() + ..terminal(metadata) }, ); session @@ -703,7 +726,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -754,45 +780,57 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match Box::pin( - session - .guardian_review_session - .run_review(GuardianReviewSessionParams { - parent_session: Arc::clone(&session), - parent_turn: turn.clone(), - spawn_config: guardian_config, - request, - retry_reason, - schema, - model: guardian_model, - reasoning_effort: guardian_reasoning_effort, - reasoning_summary: turn.reasoning_summary, - personality: turn.personality, - external_cancel, - }), - ) - .await - { + let (session_outcome, session_metadata) = Box::pin(session.guardian_review_session.run_review( + GuardianReviewSessionParams { + parent_session: Arc::clone(&session), + parent_turn: turn.clone(), + spawn_config: guardian_config, + request, + retry_reason, + schema, + model: guardian_model, + reasoning_effort: guardian_reasoning_effort, + reasoning_summary: turn.reasoning_summary, + personality: turn.personality, + external_cancel, + }, + )) + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 239754c508..7aaae56e46 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -58,10 +60,21 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) token_usage: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -101,6 +114,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -267,10 +295,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -304,16 +336,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -321,9 +354,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -349,20 +385,30 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - Box::pin(run_review_on_session(trunk.as_ref(), ¶ms, deadline)).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = Box::pin(run_review_on_session( + trunk.as_ref(), + ¶ms, + guardian_session_kind, + deadline, + )) + .await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -459,7 +505,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -478,17 +527,18 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = Box::pin(run_review_on_session( + let (outcome, _, metadata) = Box::pin(run_review_on_session( review_session.as_ref(), ¶ms, + GuardianReviewSessionKind::EphemeralForked, deadline, )) .await; @@ -496,7 +546,7 @@ impl GuardianReviewSessionManager { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -543,8 +593,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -559,6 +614,14 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -583,6 +646,8 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -602,29 +667,45 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -653,7 +734,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -662,7 +743,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -672,7 +753,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -684,18 +765,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -703,6 +786,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -954,4 +1038,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 82d4127a2b..8b5f93e894 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -917,7 +917,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() /*external_cancel*/ None, ) .await; - let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else { + let GuardianReviewOutcome::Completed(Ok(assessment), _) = outcome else { panic!("expected guardian assessment"); }; assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); @@ -1126,13 +1126,13 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { panic!("expected third guardian assessment"); }; assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); From d94e127f209f83e3424a2f675e5006875f7b5b2f Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 378/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 ++++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 ++++--- codex-rs/core/src/guardian/tests.rs | 83 +++++++++++++++++-- 6 files changed, 154 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..b3fb7f71dc 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -167,32 +167,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -382,10 +399,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 67e9a828ee..67d84c31ac 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -69,7 +69,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 911b3839e0..1ecd649e62 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -269,7 +269,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7aaae56e46..80ccb75cb0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -72,6 +72,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -620,6 +621,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -646,6 +648,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -667,8 +670,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -678,16 +682,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8b5f93e894..31f944db58 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -584,11 +584,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 9711349153ec6e824afcd401347cefbd7ebaee42 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 379/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/guardian/tests.rs | 4 ++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 19 files changed, 112 insertions(+), 3 deletions(-) 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 9e94515dd9..a390d33392 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1332,6 +1332,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1406,6 +1407,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1729,6 +1731,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2235,6 +2238,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2272,6 +2276,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2324,6 +2329,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2361,6 +2367,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2535,6 +2542,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2553,6 +2561,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2564,6 +2573,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2598,6 +2608,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2654,6 +2665,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2899,6 +2911,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2958,6 +2971,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3009,6 +3023,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3057,6 +3072,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 4fddee4a11..d5195f5960 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3042,6 +3042,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6de8909e6d..6e4e870e48 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1381,6 +1381,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1566,6 +1567,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1591,6 +1593,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1668,6 +1671,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1686,6 +1690,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1713,6 +1718,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1759,6 +1765,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1782,6 +1789,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1805,6 +1813,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4785,6 +4794,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + time_to_first_token_ms: None, .. }) if turn_id == tc.sub_id )); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 1ecd649e62..6659355581 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -271,7 +271,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 80ccb75cb0..9dbcf18ef0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -74,6 +74,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -623,6 +624,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -695,8 +697,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -740,6 +747,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -765,6 +773,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 31f944db58..8dd535c2e6 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -953,6 +953,10 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() assert_eq!(metadata.guardian_model, "gpt-5.4"); assert_eq!(metadata.guardian_reasoning_effort.as_deref(), Some("low")); assert!(!metadata.had_prior_review_context); + assert!( + metadata.time_to_first_token_ms.is_some(), + "guardian review metadata should capture TTFT when the nested turn completes" + ); let request = request_log.single_request(); let mut settings = Settings::clone_current(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f17017316e..692a5919a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -535,11 +535,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d20fadeb4..73045ba734 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2057,6 +2057,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 09b8ce06cd..2107aef4b7 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -762,6 +762,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -793,6 +794,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1..261fd47f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1350,6 +1354,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index f3746fdbf0..2cd2790ee6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 e59928ff1c..a294e3aadf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -961,6 +961,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1311,6 +1312,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2251,6 +2253,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From 8644138ef0869b14e7bfebe61e248778d0afc04d Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 380/383] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 2 + codex-rs/core/src/guardian/review.rs | 401 +++++++++++++++++++++++++-- codex-rs/core/src/guardian/tests.rs | 1 + 3 files changed, 386 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9cd58e044f..db18081785 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -753,6 +754,7 @@ fn spawn_guardian_review( review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 18107f7377..0874b92543 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,14 @@ use std::sync::Arc; +use std::time::Instant; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -10,12 +19,14 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::WarningEvent; use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -76,10 +87,34 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { Completed(anyhow::Result), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -89,6 +124,188 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} + +struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +struct GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision, + terminal_status: GuardianReviewTerminalStatus, + failure_reason: Option, + risk_level: Option, + user_authorization: Option, + outcome: Option, + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, + completed_at: u64, +} + +#[derive(Default)] +struct GuardianReviewMetadataFields { + guardian_thread_id: Option, + guardian_session_kind: Option, + guardian_model: Option, + guardian_reasoning_effort: Option, + had_prior_review_context: Option, + reviewed_action_truncated: bool, + token_usage: Option, + time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsTerminal { + fn from_metadata(metadata: GuardianReviewMetadataFields, completed_at: u64) -> Self { + Self { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: None, + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: metadata.guardian_thread_id, + guardian_session_kind: metadata.guardian_session_kind, + guardian_model: metadata.guardian_model, + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: metadata.had_prior_review_context, + reviewed_action_truncated: metadata.reviewed_action_truncated, + token_usage: metadata.token_usage, + time_to_first_token_ms: metadata.time_to_first_token_ms, + completed_at, + } + } +} + +impl GuardianReviewAnalyticsContext { + fn track( + &self, + session: &Session, + turn: &TurnContext, + terminal: GuardianReviewAnalyticsTerminal, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: terminal.reviewed_action_truncated, + decision: terminal.decision, + terminal_status: terminal.terminal_status, + failure_reason: terminal.failure_reason, + risk_level: terminal.risk_level, + user_authorization: terminal.user_authorization, + outcome: terminal.outcome, + guardian_thread_id: terminal.guardian_thread_id, + guardian_session_kind: terminal.guardian_session_kind, + guardian_model: terminal.guardian_model, + guardian_reasoning_effort: terminal.guardian_reasoning_effort, + had_prior_review_context: terminal.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: terminal.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(terminal.completed_at), + input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.input_tokens), + cached_input_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.output_tokens), + reasoning_output_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: terminal + .token_usage + .as_ref() + .map(|usage| usage.total_tokens), + }); + } +} + /// Whether this turn should route `on-request` approval prompts through the /// guardian reviewer instead of surfacing them to the user. ARC may still /// block actions earlier in the flow. @@ -116,11 +333,24 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { + let started_at = now_unix_seconds(); + let started_instant = Instant::now(); let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext { + thread_id: session.conversation_id.to_string(), + turn_id: assessment_turn_id.clone(), + review_id: review_id.clone(), + target_item_id: target_item_id.clone(), + approval_request_source, + reviewed_action: guardian_reviewed_action(&request), + started_at, + started_instant, + }; session .send_event( turn.as_ref(), @@ -142,6 +372,19 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + ..GuardianReviewAnalyticsTerminal::from_metadata( + GuardianReviewMetadataFields::default(), + now_unix_seconds(), + ) + }, + ); session .send_event( turn.as_ref(), @@ -167,24 +410,97 @@ async fn run_guardian_review( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, )) .await; + let completed_at = now_unix_seconds(); + let terminal = || { + GuardianReviewAnalyticsTerminal::from_metadata( + GuardianReviewMetadataFields::default(), + completed_at, + ) + }; let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(Ok(assessment)) => { + let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: if approved { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if approved { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + ..terminal() + }, + ); + assessment + } + GuardianReviewOutcome::Completed(Err(err)) => { + let rationale = format!("Automatic approval review failed: {err}"); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(GuardianReviewFailureReason::SessionError), + ..terminal() + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } + GuardianReviewOutcome::Failed(failure) => { + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + ..terminal() + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + ..terminal() + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +528,16 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsTerminal { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + ..terminal() + }, + ); session .send_event( turn.as_ref(), @@ -311,6 +637,7 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, )) .await @@ -322,16 +649,18 @@ pub(crate) async fn review_approval_request_with_cancel( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, cancel_token: CancellationToken, ) -> ReviewDecision { - Box::pin(run_guardian_review( + run_guardian_review( Arc::clone(session), Arc::clone(turn), review_id, request, retry_reason, + approval_request_source, Some(cancel_token), - )) + ) .await } @@ -360,7 +689,9 @@ pub(super) async fn run_guardian_review_session( let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + } }, None => None, }; @@ -410,7 +741,7 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), }; match Box::pin( @@ -432,15 +763,49 @@ pub(super) async fn run_guardian_review_session( ) .await { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), + Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + } + } + None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) } GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, } } + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); + } +} diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8940753d27..82d4127a2b 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -701,6 +701,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { .to_string(), }, /*retry_reason*/ None, + GuardianApprovalRequestSource::MainTurn, cancel_token, ) .await; From d8e456889f9e79c5e582cde0dcbe4249c6450f98 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 381/383] [codex-analytics] guardian review thread and token metadata --- codex-rs/core/src/guardian/review.rs | 136 +++++++++----- codex-rs/core/src/guardian/review_session.rs | 178 ++++++++++++++++--- codex-rs/core/src/guardian/tests.rs | 8 +- 3 files changed, 242 insertions(+), 80 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 0874b92543..2415b5fd28 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -37,6 +37,7 @@ use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; +use super::review_session::GuardianReviewSessionMetadata; use super::review_session::GuardianReviewSessionOutcome; use super::review_session::GuardianReviewSessionParams; use super::review_session::build_guardian_review_session_config; @@ -86,10 +87,13 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - Failed(GuardianReviewFailure), - TimedOut, - Aborted, + Completed( + anyhow::Result, + Option, + ), + Failed(GuardianReviewFailure, Option), + TimedOut(Option), + Aborted(Option), } #[derive(Debug)] @@ -242,6 +246,24 @@ impl GuardianReviewAnalyticsTerminal { } } +fn guardian_review_metadata_fields( + metadata: Option, +) -> GuardianReviewMetadataFields { + match metadata { + Some(metadata) => GuardianReviewMetadataFields { + guardian_thread_id: Some(metadata.guardian_thread_id), + guardian_session_kind: Some(metadata.guardian_session_kind), + guardian_model: Some(metadata.guardian_model), + guardian_reasoning_effort: metadata.guardian_reasoning_effort, + had_prior_review_context: Some(metadata.had_prior_review_context), + reviewed_action_truncated: false, + token_usage: metadata.token_usage, + time_to_first_token_ms: None, + }, + None => GuardianReviewMetadataFields::default(), + } +} + impl GuardianReviewAnalyticsContext { fn track( &self, @@ -417,15 +439,12 @@ async fn run_guardian_review( .await; let completed_at = now_unix_seconds(); - let terminal = || { - GuardianReviewAnalyticsTerminal::from_metadata( - GuardianReviewMetadataFields::default(), - completed_at, - ) - }; + let terminal = + |metadata| GuardianReviewAnalyticsTerminal::from_metadata(metadata, completed_at); let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => { + GuardianReviewOutcome::Completed(Ok(assessment), metadata) => { let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -444,12 +463,13 @@ async fn run_guardian_review( risk_level: Some(assessment.risk_level), user_authorization: Some(assessment.user_authorization), outcome: Some(assessment.outcome), - ..terminal() + ..terminal(metadata) }, ); assessment } - GuardianReviewOutcome::Completed(Err(err)) => { + GuardianReviewOutcome::Completed(Err(err), metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {err}"); analytics_context.track( session.as_ref(), @@ -458,7 +478,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::FailedClosed, failure_reason: Some(GuardianReviewFailureReason::SessionError), - ..terminal() + ..terminal(metadata) }, ); GuardianAssessment { @@ -468,7 +488,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::Failed(failure) => { + GuardianReviewOutcome::Failed(failure, metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = format!("Automatic approval review failed: {}", failure.error()); analytics_context.track( session.as_ref(), @@ -477,7 +498,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::FailedClosed, failure_reason: Some(failure.reason()), - ..terminal() + ..terminal(metadata) }, ); GuardianAssessment { @@ -487,7 +508,8 @@ async fn run_guardian_review( rationale, } } - GuardianReviewOutcome::TimedOut => { + GuardianReviewOutcome::TimedOut(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); @@ -498,7 +520,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::TimedOut, failure_reason: Some(GuardianReviewFailureReason::Timeout), - ..terminal() + ..terminal(metadata) }, ); session @@ -527,7 +549,8 @@ async fn run_guardian_review( .await; return ReviewDecision::TimedOut; } - GuardianReviewOutcome::Aborted => { + GuardianReviewOutcome::Aborted(metadata) => { + let metadata = guardian_review_metadata_fields(metadata); analytics_context.track( session.as_ref(), turn.as_ref(), @@ -535,7 +558,7 @@ async fn run_guardian_review( decision: GuardianReviewDecision::Aborted, terminal_status: GuardianReviewTerminalStatus::Aborted, failure_reason: Some(GuardianReviewFailureReason::Cancelled), - ..terminal() + ..terminal(metadata) }, ); session @@ -690,7 +713,10 @@ pub(super) async fn run_guardian_review_session( Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { - return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)); + return GuardianReviewOutcome::Failed( + GuardianReviewFailure::PromptBuild(err), + None, + ); } }, None => None, @@ -741,45 +767,57 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err)), + Err(err) => { + return GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), None); + } }; - match Box::pin( - session - .guardian_review_session - .run_review(GuardianReviewSessionParams { - parent_session: Arc::clone(&session), - parent_turn: turn.clone(), - spawn_config: guardian_config, - request, - retry_reason, - schema, - model: guardian_model, - reasoning_effort: guardian_reasoning_effort, - reasoning_summary: turn.reasoning_summary, - personality: turn.personality, - external_cancel, - }), - ) - .await - { + let (session_outcome, session_metadata) = Box::pin(session.guardian_review_session.run_review( + GuardianReviewSessionParams { + parent_session: Arc::clone(&session), + parent_turn: turn.clone(), + spawn_config: guardian_config, + request, + retry_reason, + schema, + model: guardian_model, + reasoning_effort: guardian_reasoning_effort, + reasoning_summary: turn.reasoning_summary, + personality: turn.personality, + external_cancel, + }, + )) + .await; + + match session_outcome { GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message { Some(last_agent_message) => { match parse_guardian_assessment(Some(&last_agent_message)) { - Ok(assessment) => GuardianReviewOutcome::Completed(Ok(assessment)), - Err(err) => GuardianReviewOutcome::Failed(GuardianReviewFailure::Parse(err)), + Ok(assessment) => { + GuardianReviewOutcome::Completed(Ok(assessment), session_metadata) + } + Err(err) => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), } } - None => GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(anyhow::anyhow!( - "guardian review completed without an assessment payload" - ))), + None => GuardianReviewOutcome::Failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), }, GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err)) + GuardianReviewOutcome::Failed(GuardianReviewFailure::Session(err), session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewOutcome::Failed(GuardianReviewFailure::PromptBuild(err), session_metadata) + } + GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut(session_metadata), + GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted(session_metadata), } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 239754c508..7aaae56e46 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -58,10 +60,21 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } +#[derive(Debug, Clone)] +pub(crate) struct GuardianReviewSessionMetadata { + pub(crate) guardian_thread_id: String, + pub(crate) guardian_session_kind: GuardianReviewSessionKind, + pub(crate) guardian_model: String, + pub(crate) guardian_reasoning_effort: Option, + pub(crate) had_prior_review_context: bool, + pub(crate) token_usage: Option, +} + pub(crate) struct GuardianReviewSessionParams { pub(crate) parent_session: Arc, pub(crate) parent_turn: Arc, @@ -101,6 +114,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -267,10 +295,14 @@ impl GuardianReviewSessionManager { pub(crate) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -304,16 +336,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -321,9 +354,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -349,20 +385,30 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - Box::pin(run_review_on_session(trunk.as_ref(), ¶ms, deadline)).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = Box::pin(run_review_on_session( + trunk.as_ref(), + ¶ms, + guardian_session_kind, + deadline, + )) + .await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -459,7 +505,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -478,17 +527,18 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = Box::pin(run_review_on_session( + let (outcome, _, metadata) = Box::pin(run_review_on_session( review_session.as_ref(), ¶ms, + GuardianReviewSessionKind::EphemeralForked, deadline, )) .await; @@ -496,7 +546,7 @@ impl GuardianReviewSessionManager { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -543,8 +593,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -559,6 +614,14 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -583,6 +646,8 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = + review_session.codex.session.total_token_usage().await; review_session .codex @@ -602,29 +667,45 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(token_usage_at_review_start) = token_usage_at_review_start + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -653,7 +734,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -662,7 +743,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -672,7 +753,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -684,18 +765,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -703,6 +786,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -954,4 +1038,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 82d4127a2b..8b5f93e894 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -917,7 +917,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() /*external_cancel*/ None, ) .await; - let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else { + let GuardianReviewOutcome::Completed(Ok(assessment), _) = outcome else { panic!("expected guardian assessment"); }; assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); @@ -1126,13 +1126,13 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { panic!("expected third guardian assessment"); }; assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); From 5efeb7cf2ec76744b6666dc1860e211d8317bb68 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 382/383] [codex-analytics] guardian review truncation --- .../core/src/guardian/approval_request.rs | 66 ++++++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 25 ++++-- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 28 ++++--- codex-rs/core/src/guardian/tests.rs | 83 +++++++++++++++++-- 6 files changed, 154 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..b3fb7f71dc 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value; use super::GUARDIAN_MAX_ACTION_STRING_TOKENS; -use super::prompt::guardian_truncate_text; +use super::prompt::guardian_truncate_text_with_metadata; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { @@ -167,32 +167,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text_with_metadata(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -382,10 +399,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index cfab943226..50d8fdada8 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -62,7 +62,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 761a5ec2fc..6308d2a711 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -420,20 +422,23 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text_with_metadata( + content: &str, + token_cap: usize, +) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +446,11 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) +} + +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { + guardian_truncate_text_with_metadata(content, token_cap).0 } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 2415b5fd28..4865fa746f 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -256,7 +256,7 @@ fn guardian_review_metadata_fields( guardian_model: Some(metadata.guardian_model), guardian_reasoning_effort: metadata.guardian_reasoning_effort, had_prior_review_context: Some(metadata.had_prior_review_context), - reviewed_action_truncated: false, + reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, time_to_first_token_ms: None, }, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7aaae56e46..80ccb75cb0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -72,6 +72,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) guardian_model: String, pub(crate) guardian_reasoning_effort: Option, pub(crate) had_prior_review_context: bool, + pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, } @@ -620,6 +621,7 @@ async fn run_review_on_session( guardian_model: params.model.clone(), guardian_reasoning_effort: params.reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -646,6 +648,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session.codex.session.total_token_usage().await; @@ -667,8 +670,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, Option), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, Option), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -678,16 +682,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8b5f93e894..31f944db58 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -584,11 +584,29 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment), _) = first_outcome else { + let GuardianReviewOutcome::Completed(Ok(first_assessment), first_metadata) = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment), _) = second_outcome else { + let first_metadata = first_metadata.expect("first guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment), _) = third_outcome else { + let second_metadata = second_metadata.expect("second guardian session metadata"); + let GuardianReviewOutcome::Completed(Ok(third_assessment), third_metadata) = third_outcome + else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 57a76ae8edeb74d3b6352eddc8bf61de09d4deb9 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 15 Apr 2026 15:03:50 -0700 Subject: [PATCH 383/383] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ .../src/codex/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/codex_tests.rs | 10 ++++++++++ codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 15 ++++++++++++-- codex-rs/core/src/guardian/tests.rs | 4 ++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 10 ++++++++++ codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 19 files changed, 112 insertions(+), 3 deletions(-) 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 9e94515dd9..a390d33392 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1332,6 +1332,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1406,6 +1407,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1729,6 +1731,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2235,6 +2238,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2272,6 +2276,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2324,6 +2329,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2361,6 +2367,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2535,6 +2542,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2553,6 +2561,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2564,6 +2573,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2598,6 +2608,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2654,6 +2665,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2899,6 +2911,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2958,6 +2971,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3009,6 +3023,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3057,6 +3072,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_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 4fddee4a11..d5195f5960 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3042,6 +3042,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6fc74b30e8..2e800ea198 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -275,6 +275,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1225,6 +1226,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1311,6 +1313,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 753244ac2b..f3d62a090c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -148,6 +148,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -215,6 +216,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -242,6 +244,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -311,6 +314,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -400,6 +404,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -426,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -443,6 +449,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -534,6 +542,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -601,6 +610,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -650,6 +660,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -667,6 +678,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -720,6 +732,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -876,6 +889,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -945,6 +959,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1046,6 +1061,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1153,6 +1169,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1186,6 +1203,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1268,6 +1286,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1418,6 +1437,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6de8909e6d..6e4e870e48 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1381,6 +1381,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1566,6 +1567,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1591,6 +1593,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1668,6 +1671,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1686,6 +1690,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1713,6 +1718,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1759,6 +1765,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1782,6 +1789,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1805,6 +1813,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -4785,6 +4794,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + time_to_first_token_ms: None, .. }) if turn_id == tc.sub_id )); diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 4865fa746f..edf6e463f3 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -258,7 +258,7 @@ fn guardian_review_metadata_fields( had_prior_review_context: Some(metadata.had_prior_review_context), reviewed_action_truncated: metadata.reviewed_action_truncated, token_usage: metadata.token_usage, - time_to_first_token_ms: None, + time_to_first_token_ms: metadata.time_to_first_token_ms, }, None => GuardianReviewMetadataFields::default(), } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 80ccb75cb0..9dbcf18ef0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -74,6 +74,7 @@ pub(crate) struct GuardianReviewSessionMetadata { pub(crate) had_prior_review_context: bool, pub(crate) reviewed_action_truncated: bool, pub(crate) token_usage: Option, + pub(crate) time_to_first_token_ms: Option, } pub(crate) struct GuardianReviewSessionParams { @@ -623,6 +624,7 @@ async fn run_review_on_session( had_prior_review_context: had_prior_review_context(&prompt_mode), reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -695,8 +697,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(token_usage_at_review_start) = token_usage_at_review_start @@ -740,6 +747,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -765,6 +773,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 31f944db58..8dd535c2e6 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -953,6 +953,10 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() assert_eq!(metadata.guardian_model, "gpt-5.4"); assert_eq!(metadata.guardian_reasoning_effort.as_deref(), Some("low")); assert!(!metadata.had_prior_review_context); + assert!( + metadata.time_to_first_token_ms.is_some(), + "guardian review metadata should capture TTFT when the nested turn completes" + ); let request = request_log.single_request(); let mut settings = Settings::clone_current(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f17017316e..692a5919a3 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -535,11 +535,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e35d6a06fb..7c55be087d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1154,6 +1154,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1631,6 +1632,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1659,6 +1661,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 6c47d3b528..c8692f1976 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,16 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + let started_at = state.started_at?; + let first_token_at = state.first_token_at?; + Some( + i64::try_from(first_token_at.duration_since(started_at).as_millis()) + .unwrap_or(i64::MAX), + ) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f9ad64c771..70c9014747 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7d20fadeb4..73045ba734 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2057,6 +2057,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 09b8ce06cd..2107aef4b7 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -762,6 +762,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -793,6 +794,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1..261fd47f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1350,6 +1354,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index f3746fdbf0..2cd2790ee6 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -546,6 +546,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -572,6 +573,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -586,6 +588,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -609,6 +612,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..9a05320aa6 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -234,6 +234,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -255,6 +256,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2afb959349..27cd96e388 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -225,6 +225,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -257,6 +258,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -343,6 +345,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -373,6 +376,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -410,6 +414,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); 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 e59928ff1c..a294e3aadf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -961,6 +961,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1311,6 +1312,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2251,6 +2253,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) {